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 audit;
26pub use audit::{AuditVerifyReport, verify_audit_file};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod error;
30pub(crate) mod git_browser;
31pub(crate) mod git_webhook;
32pub(crate) mod integrations;
33
34use std::{
35    collections::{HashMap, VecDeque},
36    fmt::Write,
37    fs,
38    net::{IpAddr, SocketAddr},
39    path::{Path, PathBuf},
40    process::Stdio,
41    sync::{Arc, OnceLock},
42    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
43};
44
45use anyhow::{Context, Result};
46use askama::Template;
47use axum::{
48    Json, Router,
49    body::Body,
50    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
51    http::{HeaderValue, Request, StatusCode, header},
52    middleware::{self, Next},
53    response::{Html, IntoResponse, Response},
54    routing::{get, post},
55};
56use serde::{Deserialize, Serialize};
57use tokio::sync::Mutex;
58use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
59
60use sloc_config::{
61    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
62    MixedLinePolicy,
63};
64use sloc_git::ScheduleStore;
65
66#[derive(Clone)]
67pub(crate) struct CspNonce(pub(crate) String);
68
69static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
70static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
71
72use sloc_core::{
73    AnalysisRun, CleanupPolicy, CleanupPolicyStore, FileChangeStatus, MultiScanComparison,
74    RegistryEntry, ScanRegistry, ScanSummarySnapshot, SummaryTotals, WatchedDirsStore, analyze,
75    compute_delta, compute_multi_delta, read_json,
76};
77use sloc_report::{
78    ReportDeltaContext, render_html, render_html_with_delta, render_sub_report_html,
79    write_pdf_from_html, write_pdf_from_run,
80};
81const MAX_CONCURRENT_ANALYSES: usize = 4;
82
83/// Windows-only helpers that force the native file-picker dialog into the
84/// foreground instead of appearing minimised behind other windows.
85///
86/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
87/// foreground thread so that windows created on our thread inherit focus; and
88/// (b) spin a polling watcher that finds the dialog by title and calls
89/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
90#[cfg(target_os = "windows")]
91#[allow(clippy::upper_case_acronyms)]
92#[allow(dead_code)]
93mod win_dialog_focus {
94    #[cfg(feature = "native-dialog")]
95    use std::mem::size_of;
96
97    type HWND = *mut core::ffi::c_void;
98    type DWORD = u32;
99    type UINT = u32;
100    type BOOL = i32;
101
102    // Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
103    #[cfg(feature = "native-dialog")]
104    #[repr(C)]
105    #[allow(non_snake_case)]
106    struct FLASHWINFO {
107        cbSize: UINT,
108        hwnd: HWND,
109        dwFlags: DWORD,
110        uCount: UINT,
111        dwTimeout: DWORD,
112    }
113
114    #[cfg(feature = "native-dialog")]
115    const FLASHW_ALL: DWORD = 0x3;
116    #[cfg(feature = "native-dialog")]
117    const FLASHW_TIMERNOFG: DWORD = 0xC;
118
119    #[link(name = "user32")]
120    unsafe extern "system" {
121        fn GetForegroundWindow() -> HWND;
122        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
123        fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
124        fn BringWindowToTop(hWnd: HWND) -> BOOL;
125        fn SetWindowPos(
126            hWnd: HWND,
127            hWndAfter: HWND,
128            x: i32,
129            y: i32,
130            cx: i32,
131            cy: i32,
132            flags: UINT,
133        ) -> BOOL;
134        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
135        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
136        #[cfg(feature = "native-dialog")]
137        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
138        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
139        fn FindWindowExW(
140            hWndParent: HWND,
141            hWndChildAfter: HWND,
142            lpszClass: *const u16,
143            lpszWindow: *const u16,
144        ) -> HWND;
145        // Undocumented but present on all Windows versions since XP; bypasses
146        // the foreground-lock that blocks SetForegroundWindow from non-foreground
147        // processes.  fAltTab=1 simulates the Alt+Tab activation path.
148        fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
149    }
150
151    #[link(name = "kernel32")]
152    unsafe extern "system" {
153        fn GetCurrentThreadId() -> DWORD;
154    }
155
156    #[link(name = "shell32")]
157    unsafe extern "system" {
158        // Opens a folder (or file) via the Windows shell.  Passing the current
159        // foreground window as `hwnd` gives the new window proper activation
160        // context so it surfaces in the foreground without needing
161        // AttachThreadInput or SetForegroundWindow hacks.
162        fn ShellExecuteW(
163            hwnd: HWND,
164            lpOperation: *const u16,
165            lpFile: *const u16,
166            lpParameters: *const u16,
167            lpDirectory: *const u16,
168            nShowCmd: i32,
169        ) -> isize; // HINSTANCE (>32 = success)
170    }
171
172    /// Attaches our thread's input to the foreground window's thread so that
173    /// windows created on our thread inherit foreground focus.  Returns the
174    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
175    /// the thread was already the foreground thread.
176    #[cfg(feature = "native-dialog")]
177    pub fn attach_to_foreground() -> DWORD {
178        unsafe {
179            let fg_hwnd = GetForegroundWindow();
180            if fg_hwnd.is_null() {
181                return 0;
182            }
183            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
184            let my_tid = GetCurrentThreadId();
185            if fg_tid == my_tid {
186                return 0;
187            }
188            AttachThreadInput(my_tid, fg_tid, 1);
189            fg_tid
190        }
191    }
192
193    /// Undoes `attach_to_foreground`.
194    #[cfg(feature = "native-dialog")]
195    pub fn detach_from_foreground(fg_tid: DWORD) {
196        if fg_tid == 0 {
197            return;
198        }
199        unsafe {
200            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
201        }
202    }
203
204    unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
205        unsafe {
206            let mut existing = std::collections::HashSet::new();
207            let mut prev: HWND = core::ptr::null_mut();
208            loop {
209                let w = FindWindowExW(
210                    core::ptr::null_mut(),
211                    prev,
212                    class_w.as_ptr(),
213                    core::ptr::null(),
214                );
215                if w.is_null() {
216                    break;
217                }
218                existing.insert(w as usize);
219                prev = w;
220            }
221            existing
222        }
223    }
224
225    unsafe fn find_new_explorer_hwnd(
226        class_w: &[u16],
227        existing: &std::collections::HashSet<usize>,
228    ) -> Option<HWND> {
229        unsafe {
230            let mut prev: HWND = core::ptr::null_mut();
231            loop {
232                let w = FindWindowExW(
233                    core::ptr::null_mut(),
234                    prev,
235                    class_w.as_ptr(),
236                    core::ptr::null(),
237                );
238                if w.is_null() {
239                    return None;
240                }
241                if !existing.contains(&(w as usize)) {
242                    return Some(w);
243                }
244                prev = w;
245            }
246        }
247    }
248
249    unsafe fn bring_to_front(hwnd: HWND) {
250        unsafe {
251            // Surfacing a window owned by another process (Explorer) from a
252            // background thread is blocked by Windows' foreground lock:
253            // SetForegroundWindow silently fails and only the taskbar button
254            // flashes.  The reliable workaround is to temporarily attach our input
255            // queue to the thread that currently owns the foreground window — while
256            // attached, SetForegroundWindow/BringWindowToTop actually activate the
257            // window instead of merely flashing it.
258            let my_tid = GetCurrentThreadId();
259            let fg_hwnd = GetForegroundWindow();
260            let fg_tid = if fg_hwnd.is_null() {
261                0
262            } else {
263                GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
264            };
265            let attached =
266                fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
267
268            // SW_RESTORE = 9 — un-minimise the Explorer window (it may have opened
269            // as a taskbar button) without forcing a full-screen maximise.
270            ShowWindow(hwnd, 9);
271            BringWindowToTop(hwnd);
272            SetForegroundWindow(hwnd);
273            // Extra belt-and-braces activation that also bypasses the foreground
274            // lock on older Windows builds.
275            SwitchToThisWindow(hwnd, 1);
276
277            // Force the Z-order to the very top regardless of the foreground-lock
278            // outcome by flipping TOPMOST on then off, so the window jumps above all
279            // others without staying pinned. HWND_TOPMOST = -1, HWND_NOTOPMOST = -2;
280            // SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE = 0x0013.
281            SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
282            SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
283
284            if attached {
285                AttachThreadInput(my_tid, fg_tid, 0);
286            }
287        }
288    }
289
290    /// Opens `path` in Windows Explorer and forces it to the foreground.
291    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
292    /// caller is not the foreground process (the browser is).  After launching,
293    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
294    /// an undocumented API that bypasses Windows' foreground-lock restriction
295    /// so the window surfaces regardless of which process currently has focus.
296    pub fn open_folder_foreground(path: std::path::PathBuf) {
297        std::thread::spawn(move || {
298            use std::os::windows::ffi::OsStrExt;
299
300            let op: Vec<u16> = "explore\0".encode_utf16().collect();
301            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
302            path_w.push(0);
303            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
304
305            unsafe {
306                // Snapshot every existing Explorer window before we launch so
307                // we can identify the newly created one.
308                let existing = snapshot_explorer_hwnds(&class_w);
309                let fg_hwnd = GetForegroundWindow();
310                // SW_SHOWNORMAL = 1
311                ShellExecuteW(
312                    fg_hwnd,
313                    op.as_ptr(),
314                    path_w.as_ptr(),
315                    core::ptr::null(),
316                    core::ptr::null(),
317                    1,
318                );
319
320                // Poll up to ~3 s for a new CabinetWClass window to appear,
321                // then use SwitchToThisWindow (bypasses foreground-lock) to
322                // bring it in front of the browser and everything else.
323                for _ in 0..40 {
324                    std::thread::sleep(std::time::Duration::from_millis(75));
325                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
326                        bring_to_front(w);
327                        return;
328                    }
329                }
330
331                // Fallback: Explorer reused an existing window — bring whichever
332                // CabinetWClass window is first in Z-order to the front.
333                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
334                if !w.is_null() {
335                    bring_to_front(w);
336                }
337            }
338        });
339    }
340
341    /// Spawns a short-lived watcher thread that polls for a dialog window
342    /// matching `title` and, once found, forces it to the foreground and
343    /// flashes its taskbar button until the user interacts with it.
344    #[cfg(feature = "native-dialog")]
345    pub fn flash_dialog_when_ready(title: String) {
346        std::thread::spawn(move || {
347            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
348            for _ in 0..40 {
349                std::thread::sleep(std::time::Duration::from_millis(80));
350                unsafe {
351                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
352                    if !hwnd.is_null() {
353                        SetForegroundWindow(hwnd);
354                        BringWindowToTop(hwnd);
355                        #[allow(non_snake_case)]
356                        FlashWindowEx(&FLASHWINFO {
357                            // size_of returns usize; Win32 struct field is u32 (UINT).
358                            // struct size fits trivially within u32.
359                            #[allow(clippy::cast_possible_truncation)]
360                            cbSize: size_of::<FLASHWINFO>() as UINT,
361                            hwnd,
362                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
363                            uCount: 3,
364                            dwTimeout: 0,
365                        });
366                        break;
367                    }
368                }
369            }
370        });
371    }
372}
373
374/// Sliding-window rate limiter keyed by client IP.
375/// Uses only std primitives — no external crate required.
376pub(crate) struct IpRateLimiter {
377    window: Duration,
378    max_requests: usize,
379    pub(crate) auth_lockout_threshold: u32,
380    auth_lockout_window: Duration,
381    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
382    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
383}
384
385impl IpRateLimiter {
386    pub(crate) fn new(
387        window: Duration,
388        max_requests: usize,
389        auth_lockout_threshold: u32,
390        auth_lockout_window: Duration,
391    ) -> Self {
392        Self {
393            window,
394            max_requests,
395            auth_lockout_threshold,
396            auth_lockout_window,
397            state: std::sync::Mutex::new(HashMap::new()),
398            auth_failures: std::sync::Mutex::new(HashMap::new()),
399        }
400    }
401
402    // The MutexGuard `state` must live as long as `bucket` borrows from it,
403    // so it cannot be dropped any earlier than the end of the inner block.
404    #[allow(clippy::significant_drop_tightening)]
405    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
406        let now = Instant::now();
407        let cutoff = now.checked_sub(self.window).unwrap_or(now);
408        let mut state = self
409            .state
410            .lock()
411            .unwrap_or_else(std::sync::PoisonError::into_inner);
412        if state.len() > 10_000 {
413            state.retain(|_, bucket| {
414                while bucket.front().is_some_and(|t| *t <= cutoff) {
415                    bucket.pop_front();
416                }
417                !bucket.is_empty()
418            });
419        }
420        let bucket = state.entry(ip).or_default();
421        while bucket.front().is_some_and(|t| *t <= cutoff) {
422            bucket.pop_front();
423        }
424        if bucket.len() >= self.max_requests {
425            false
426        } else {
427            bucket.push_back(now);
428            true
429        }
430    }
431
432    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
433        let now = Instant::now();
434        let mut map = self
435            .auth_failures
436            .lock()
437            .unwrap_or_else(std::sync::PoisonError::into_inner);
438        map.entry(ip)
439            .and_modify(|e| {
440                e.0 += 1;
441                e.1 = now;
442            })
443            .or_insert_with(|| (1, now));
444    }
445
446    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
447        let mut map = self
448            .auth_failures
449            .lock()
450            .unwrap_or_else(std::sync::PoisonError::into_inner);
451        let expired = map
452            .get(&ip)
453            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
454        if expired {
455            map.remove(&ip);
456            return false;
457        }
458        map.get(&ip)
459            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
460    }
461
462    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
463        let map = self
464            .auth_failures
465            .lock()
466            .unwrap_or_else(std::sync::PoisonError::into_inner);
467        map.get(&ip).map_or(0, |e| {
468            self.auth_lockout_window
469                .checked_sub(e.1.elapsed())
470                .map_or(0, |r| r.as_secs())
471        })
472    }
473
474    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
475        tokio::spawn(async move {
476            let mut interval = tokio::time::interval(Duration::from_mins(1));
477            interval.tick().await; // consume the immediate first tick
478            loop {
479                interval.tick().await;
480                let now = Instant::now();
481                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
482                {
483                    let mut state = limiter
484                        .state
485                        .lock()
486                        .unwrap_or_else(std::sync::PoisonError::into_inner);
487                    state.retain(|_, bucket| {
488                        while bucket.front().is_some_and(|t| *t <= cutoff) {
489                            bucket.pop_front();
490                        }
491                        !bucket.is_empty()
492                    });
493                }
494                {
495                    let mut auth = limiter
496                        .auth_failures
497                        .lock()
498                        .unwrap_or_else(std::sync::PoisonError::into_inner);
499                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
500                }
501            }
502        });
503    }
504}
505
506/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
507/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
508/// files but never triggers a scan.
509fn spawn_upload_staging_cleanup() {
510    tokio::spawn(async move {
511        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
512            .ok()
513            .and_then(|v| v.parse().ok())
514            .unwrap_or(4);
515        let ttl_secs = ttl_hours * 3600;
516        let mut interval = tokio::time::interval(Duration::from_hours(1));
517        interval.tick().await; // consume the immediate first tick
518        loop {
519            interval.tick().await;
520            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
521            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
522                continue;
523            };
524            while let Ok(Some(entry)) = dir.next_entry().await {
525                let path = entry.path();
526                let age_secs = tokio::fs::metadata(&path)
527                    .await
528                    .ok()
529                    .and_then(|m| m.modified().ok())
530                    .and_then(|t| t.elapsed().ok())
531                    .map_or(0, |d| d.as_secs());
532                if age_secs > ttl_secs {
533                    tracing::debug!(
534                        event = "upload_staging_cleanup",
535                        path = %path.display(),
536                        age_secs,
537                        "removing stale upload staging directory"
538                    );
539                    let _ = tokio::fs::remove_dir_all(&path).await;
540                }
541            }
542        }
543    });
544}
545
546/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
547#[derive(Clone, Debug, Default)]
548struct RunResultContext {
549    prev_entry: Option<RegistryEntry>,
550    prev_scan_count: usize,
551    project_path: String,
552    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
553    cocomo_mode: String,
554    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
555    complexity_alert: u32,
556    /// Whether duplicate files should be excluded from displayed SLOC totals.
557    #[allow(dead_code)]
558    exclude_duplicates: bool,
559}
560
561/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
562#[derive(Clone)]
563enum AsyncRunState {
564    Running {
565        started_at: std::time::Instant,
566        cancel_token: Arc<std::sync::atomic::AtomicBool>,
567        phase: Arc<std::sync::Mutex<String>>,
568        files_done: Arc<std::sync::atomic::AtomicUsize>,
569        files_total: Arc<std::sync::atomic::AtomicUsize>,
570    },
571    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
572    Complete {
573        run_id: String,
574    },
575    Failed {
576        message: String,
577    },
578    Cancelled,
579}
580
581/// A saved scan configuration profile — stores the form parameters so users can
582/// re-run a favourite scan with one click.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584struct ScanProfile {
585    id: String,
586    name: String,
587    created_at: String,
588    /// The raw scan-form parameters serialized as JSON.
589    params: serde_json::Value,
590}
591
592#[derive(Debug, Clone, Default, Serialize, Deserialize)]
593struct ScanProfileStore {
594    profiles: Vec<ScanProfile>,
595}
596
597impl ScanProfileStore {
598    fn load(path: &std::path::Path) -> Self {
599        fs::read_to_string(path)
600            .ok()
601            .and_then(|s| serde_json::from_str(&s).ok())
602            .unwrap_or_default()
603    }
604
605    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
606        if let Some(parent) = path.parent() {
607            fs::create_dir_all(parent)?;
608        }
609        let json = serde_json::to_string_pretty(self)?;
610        fs::write(path, json)?;
611        Ok(())
612    }
613}
614
615/// Server-side session record. `absolute_expiry` is the hard 8-hour cap (unchanged);
616/// `last_seen` supports the optional sliding idle timeout (see `session_idle_timeout`).
617#[derive(Clone, Copy)]
618pub(crate) struct SessionState {
619    pub(crate) absolute_expiry: Instant,
620    pub(crate) last_seen: Instant,
621}
622
623// The bool fields below are independent runtime flags (server mode, unauth-allow,
624// TLS, proxy trust), not a state machine. Folding them into an enum/sub-struct would
625// churn every construction and access site across this crate for no clarity gain —
626// and that mechanical churn is exactly what risks the new_duplicated_lines_density
627// gate. Scope the allow to this struct rather than refactoring.
628#[allow(clippy::struct_excessive_bools)]
629#[derive(Clone)]
630pub(crate) struct AppState {
631    pub(crate) base_config: AppConfig,
632    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
633    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
634    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
635    pub(crate) registry_path: PathBuf,
636    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
637    pub(crate) server_mode: bool,
638    /// Operator explicitly accepted running server mode with no API key
639    /// (`SLOC_ALLOW_UNAUTHENTICATED=1`). When false, an unauthenticated server-mode
640    /// request fails closed with 503 instead of being served open.
641    pub(crate) allow_unauthenticated: bool,
642    pub(crate) tls_enabled: bool,
643    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
644    /// Read-only credentials (`SLOC_API_KEYS_READONLY`): authenticate for safe
645    /// (GET/HEAD/OPTIONS) requests but are rejected on state-changing methods.
646    /// Empty by default, so all keys are full-access — the prior behaviour.
647    pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
648    pub(crate) rate_limiter: Arc<IpRateLimiter>,
649    pub(crate) trust_proxy: bool,
650    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
651    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
652    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
653    /// Directory where remote repositories are cloned for git-browser scans.
654    pub(crate) git_clones_dir: PathBuf,
655    /// Persisted list of webhook / poll schedules.
656    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
657    pub(crate) schedules_path: PathBuf,
658    /// Named scan profiles saved by the user via the web UI.
659    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
660    pub(crate) scan_profiles_path: PathBuf,
661    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
662    /// Persisted Confluence integration settings.
663    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
664    pub(crate) confluence_path: PathBuf,
665    /// Directories the user has pinned for auto-scanning of external reports.
666    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
667    pub(crate) watched_dirs_path: PathBuf,
668    /// Persisted auto-cleanup policy (age/count limits + interval).
669    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
670    pub(crate) cleanup_policy_path: PathBuf,
671    /// Handle for the running cleanup background task; replaced on policy change.
672    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
673}
674
675type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
676
677/// Parameters for the fire-and-forget HTML + PDF background task.
678
679#[derive(Clone, Debug)]
680pub(crate) struct RunArtifacts {
681    output_dir: PathBuf,
682    html_path: Option<PathBuf>,
683    pdf_path: Option<PathBuf>,
684    json_path: Option<PathBuf>,
685    csv_path: Option<PathBuf>,
686    xlsx_path: Option<PathBuf>,
687    scan_config_path: Option<PathBuf>,
688    report_title: String,
689    result_context: RunResultContext,
690}
691
692#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
693fn build_router(state: AppState) -> Router {
694    let protected = Router::new()
695        .route("/", get(splash))
696        .route("/scan-setup", get(scan_setup_handler))
697        .route("/scan", get(index))
698        .route("/analyze", post(analyze_handler))
699        .route("/preview", get(preview_handler))
700        .route("/api/suggest-coverage", get(api_suggest_coverage))
701        .route("/pick-directory", get(pick_directory_handler))
702        .route("/open-path", get(open_path_handler))
703        .route("/pick-file", get(pick_file_handler))
704        .route(
705            "/api/upload-directory",
706            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
707        )
708        .route(
709            "/api/upload-file",
710            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
711        )
712        .route(
713            "/api/upload-tarball",
714            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
715            // The handler also enforces this limit during streaming so both layers agree.
716            post(upload_tarball_handler)
717                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
718        )
719        .route("/locate-report", post(locate_report_handler))
720        .route("/locate-reports-dir", post(locate_reports_dir_handler))
721        .route("/relocate-scan", post(relocate_scan_handler))
722        .route("/watched-dirs/add", post(add_watched_dir_handler))
723        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
724        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
725        .route("/view-reports", get(history_handler))
726        .route("/compare-scans", get(compare_select_handler))
727        .route("/compare", get(compare_handler))
728        .route("/multi-compare", get(multi_compare_handler))
729        .route("/images/{folder}/{file}", get(image_handler))
730        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
731        .route("/api/metrics/latest", get(api_metrics_latest_handler))
732        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
733        .route("/api/metrics/history", get(api_metrics_history_handler))
734        .route("/api/metrics/churn", get(api_metrics_churn_handler))
735        .route(
736            "/api/metrics/submodules",
737            get(api_metrics_submodules_handler),
738        )
739        .route("/api/ingest", post(api_ingest_handler))
740        .route("/api/project-history", get(project_history_handler))
741        .route("/trend-reports", get(trend_report_handler))
742        .route("/test-metrics", get(test_metrics_handler))
743        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
744        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
745        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
746        .route("/runs/result/{run_id}", get(async_run_result_handler))
747        .route("/embed/summary", get(embed_handler))
748        // ── Git browser ────────────────────────────────────────────────────────
749        .route("/git-browser", get(git_browser::git_browser_handler))
750        .route("/api/git/refs", get(git_browser::api_list_refs))
751        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
752        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
753        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
754        // The request body is the full rendered HTML report, whose size scales
755        // with file count — large repos (Compare Scans, Files, Trend, Test
756        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
757        .route(
758            "/export/pdf",
759            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
760        )
761        // ── Config export / import ─────────────────────────────────────────────
762        .route("/export-config", get(export_config_handler))
763        .route("/import-config", post(import_config_handler))
764        // ── Scan profiles ──────────────────────────────────────────────────────
765        .route("/api/scan-profiles", get(api_list_scan_profiles))
766        .route("/api/scan-profiles", post(api_save_scan_profile))
767        .route(
768            "/api/scan-profiles/{id}",
769            axum::routing::delete(api_delete_scan_profile),
770        )
771        // ── Integrations (webhooks + Confluence) ──────────────────────────────
772        .route("/integrations", get(integrations::integrations_handler))
773        .route(
774            "/webhook-setup",
775            get(|| async { axum::response::Redirect::permanent("/integrations") }),
776        )
777        .route(
778            "/confluence-setup",
779            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
780        )
781        .route("/api/schedules", get(git_webhook::api_list_schedules))
782        .route("/api/schedules", post(git_webhook::api_create_schedule))
783        .route(
784            "/api/schedules",
785            axum::routing::delete(git_webhook::api_delete_schedule),
786        )
787        .route(
788            "/api/confluence/config",
789            get(confluence::api_get_confluence_config),
790        )
791        .route(
792            "/api/confluence/config",
793            post(confluence::api_save_confluence_config),
794        )
795        .route(
796            "/api/confluence/test",
797            post(confluence::api_test_confluence),
798        )
799        .route(
800            "/api/confluence/post",
801            post(confluence::api_post_to_confluence),
802        )
803        .route(
804            "/api/confluence/wiki-markup",
805            get(confluence::api_wiki_markup),
806        )
807        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
808        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
809        .route(
810            "/api/runs/{run_id}",
811            axum::routing::delete(delete_run_handler),
812        )
813        .route("/api/runs/cleanup", post(cleanup_runs_handler))
814        // ── Auto-cleanup policy ────────────────────────────────────────────────
815        .route(
816            "/api/cleanup-policy",
817            get(api_get_cleanup_policy)
818                .post(api_save_cleanup_policy)
819                .delete(api_delete_cleanup_policy),
820        )
821        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
822        // ── REST API reference page ────────────────────────────────────────────
823        .route("/api-docs", get(api_docs_handler))
824        // ── Prometheus metrics — behind API-key auth ───────────────────────────
825        .route("/metrics", get(metrics_handler))
826        .route_layer(middleware::from_fn_with_state(
827            state.clone(),
828            auth::require_api_key,
829        ));
830
831    protected
832        .route("/healthz", get(healthz))
833        .route("/readyz", get(readyz))
834        .route("/api/health", get(api_health_handler))
835        .route("/api/version", get(api_version_handler))
836        .route("/api/openapi.yaml", get(openapi_yaml_handler))
837        .route("/llms.txt", get(llms_txt_handler))
838        .route("/llms-full.txt", get(llms_full_txt_handler))
839        .route("/badge/{metric}", get(badge_handler))
840        .route("/static/chart.js", get(chart_js_handler))
841        .route("/static/chart-report.js", get(report_chart_js_handler))
842        .route("/auth/login", get(auth::auth_login_get))
843        .route("/auth/login", post(auth::auth_login_post))
844        .route("/auth/logout", post(auth::auth_logout))
845        // Pre-access consent acknowledgement endpoint (public; exempt from the gate).
846        .route("/auth/consent", get(auth::auth_consent_accept))
847        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
848        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
849        .route(
850            "/webhooks/github",
851            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
852        )
853        .route(
854            "/webhooks/gitlab",
855            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
856        )
857        .route(
858            "/webhooks/bitbucket",
859            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
860        )
861        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
862        .layer(middleware::from_fn(consent_gate))
863        .layer(middleware::from_fn(csrf_protect))
864        .layer(middleware::from_fn_with_state(
865            state.clone(),
866            add_security_headers,
867        ))
868        .layer(build_cors_layer(state.server_mode))
869        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
870        // Transparently gzip large text/JSON responses when the client accepts it.
871        .layer(middleware::from_fn(compress_response))
872        // Outermost: bound total request time as a safety net against hung/slow
873        // connections. Generous by default so real scans/PDF exports aren't cut off.
874        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
875            axum::http::StatusCode::REQUEST_TIMEOUT,
876            http_timeout(),
877        ))
878        .with_state(state)
879}
880
881/// Whole-request timeout applied as the outermost layer. A generous safety net
882/// against hung or slow-loris connections that does not cut off legitimate long
883/// operations (large-repo scans, PDF export). Override with
884/// `SLOC_HTTP_TIMEOUT_SECS`; `0` effectively disables it (24h ceiling).
885fn http_timeout() -> std::time::Duration {
886    let secs = std::env::var("SLOC_HTTP_TIMEOUT_SECS")
887        .ok()
888        .and_then(|s| s.trim().parse::<u64>().ok())
889        .unwrap_or(600);
890    std::time::Duration::from_secs(if secs == 0 { 86_400 } else { secs })
891}
892
893// ── Response compression (hand-rolled gzip via flate2) ─────────────────────────
894// A dependency-free alternative to tower-http's CompressionLayer (whose
895// async-compression crate is not in the offline vendor tree). Buffers and gzips
896// only text-like responses of a worthwhile, known size; streaming, already-encoded,
897// or binary/precompressed responses pass through untouched.
898
899/// Don't bother compressing tiny bodies (header overhead outweighs the win).
900const COMPRESS_MIN_BYTES: u64 = 1024;
901/// Never buffer a body larger than this to compress it (memory safety cap).
902const COMPRESS_MAX_BYTES: u64 = 32 * 1024 * 1024;
903
904/// True when the client's `Accept-Encoding` lists gzip.
905fn client_accepts_gzip(headers: &axum::http::HeaderMap) -> bool {
906    headers
907        .get(header::ACCEPT_ENCODING)
908        .and_then(|v| v.to_str().ok())
909        .is_some_and(|val| {
910            val.split(',').any(|enc| {
911                enc.split(';')
912                    .next()
913                    .unwrap_or("")
914                    .trim()
915                    .eq_ignore_ascii_case("gzip")
916            })
917        })
918}
919
920/// Compress text-like payloads only; binary/precompressed types (pdf, gzip, zip,
921/// images, octet-stream) gain nothing and are skipped.
922fn is_compressible_type(content_type: &str) -> bool {
923    let ct = content_type
924        .split(';')
925        .next()
926        .unwrap_or("")
927        .trim()
928        .to_ascii_lowercase();
929    ct.starts_with("text/")
930        || matches!(
931            ct.as_str(),
932            "application/json"
933                | "application/javascript"
934                | "application/xml"
935                | "application/yaml"
936                | "application/manifest+json"
937                | "image/svg+xml"
938        )
939}
940
941/// Middleware: transparently gzip eligible responses when the client accepts it.
942async fn compress_response(req: Request<Body>, next: Next) -> Response {
943    let accepts_gzip = client_accepts_gzip(req.headers());
944    let resp = next.run(req).await;
945    // Skip when the client can't take gzip or the response is already encoded.
946    if !accepts_gzip || resp.headers().contains_key(header::CONTENT_ENCODING) {
947        return resp;
948    }
949    let content_type = resp
950        .headers()
951        .get(header::CONTENT_TYPE)
952        .and_then(|v| v.to_str().ok())
953        .unwrap_or("")
954        .to_owned();
955    if !is_compressible_type(&content_type) {
956        return resp;
957    }
958
959    let (mut parts, body) = resp.into_parts();
960    // Only compress bodies whose exact size is known and worthwhile; pass
961    // streaming/unknown or out-of-band sizes through without buffering.
962    let eligible = matches!(
963        http_body::Body::size_hint(&body).exact(),
964        Some(n) if (COMPRESS_MIN_BYTES..=COMPRESS_MAX_BYTES).contains(&n)
965    );
966    if !eligible {
967        return Response::from_parts(parts, body);
968    }
969
970    let bytes = match axum::body::to_bytes(body, COMPRESS_MAX_BYTES as usize).await {
971        Ok(b) => b,
972        // Guarded against by the size check above; degrade gracefully if hit.
973        Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
974    };
975
976    use std::io::Write as _;
977    let mut encoder = flate2::write::GzEncoder::new(
978        Vec::with_capacity(bytes.len() / 2),
979        flate2::Compression::default(),
980    );
981    if encoder.write_all(&bytes).is_err() {
982        return Response::from_parts(parts, Body::from(bytes));
983    }
984    let compressed = match encoder.finish() {
985        Ok(c) => c,
986        Err(_) => return Response::from_parts(parts, Body::from(bytes)),
987    };
988
989    parts.headers.remove(header::CONTENT_LENGTH);
990    parts
991        .headers
992        .insert(header::CONTENT_LENGTH, HeaderValue::from(compressed.len()));
993    parts
994        .headers
995        .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
996    parts
997        .headers
998        .append(header::VARY, HeaderValue::from_static("accept-encoding"));
999    Response::from_parts(parts, Body::from(compressed))
1000}
1001
1002/// Bearer token used by `make_test_router_server_mode()` test routers.
1003/// Tests that exercise server-mode paths must include this key in their requests.
1004pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
1005
1006/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
1007/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
1008/// builders below start from this and override only the fields they care about.
1009///
1010/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
1011fn test_app_state(tmp_subdir: &str) -> AppState {
1012    // Root every router in its OWN temp subdirectory. Multiple routers share a
1013    // namespace prefix (e.g. "sloc_test"), so a fixed name would make parallel
1014    // tests read/write the same registry.json + artifact tree and race — a
1015    // concurrently-mutated shared store is what made multi_compare_* flaky.
1016    // A per-call counter (plus PID, to avoid leftover-dir collisions across
1017    // runs) guarantees isolation, honouring this fn's "per-test subdir" contract.
1018    static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1019    // FIXME: Audit that the environment access only happens in single-threaded code.
1020    unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
1021    let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1022    let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
1023    AppState {
1024        base_config: AppConfig::default(),
1025        artifacts: Arc::new(Mutex::new(HashMap::new())),
1026        async_runs: Arc::new(Mutex::new(HashMap::new())),
1027        registry: Arc::new(Mutex::new(ScanRegistry::default())),
1028        registry_path: tmp.join("registry.json"),
1029        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1030        server_mode: false,
1031        allow_unauthenticated: false,
1032        tls_enabled: false,
1033        api_keys: Arc::new(vec![]),
1034        readonly_api_keys: Arc::new(vec![]),
1035        rate_limiter: Arc::new(IpRateLimiter::new(
1036            Duration::from_mins(1),
1037            600,
1038            10,
1039            Duration::from_hours(1),
1040        )),
1041        trust_proxy: false,
1042        trusted_proxy_ips: vec![],
1043        git_clones_dir: tmp.join("git-clones"),
1044        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
1045        schedules_path: tmp.join("schedules.json"),
1046        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
1047        scan_profiles_path: tmp.join("scan_profiles.json"),
1048        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1049        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
1050        confluence_path: tmp.join("confluence_config.json"),
1051        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
1052        watched_dirs_path: tmp.join("watched_dirs.json"),
1053        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
1054        cleanup_policy_path: tmp.join("cleanup_policy.json"),
1055        cleanup_task_handle: Arc::new(Mutex::new(None)),
1056    }
1057}
1058
1059/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
1060pub fn make_test_router() -> Router {
1061    build_router(test_app_state("sloc_test"))
1062}
1063
1064/// Test router with one API key pre-loaded. Used by auth integration tests.
1065pub fn make_test_router_with_key(api_key: &str) -> Router {
1066    let mut state = test_app_state("sloc_test_key");
1067    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1068    build_router(state)
1069}
1070
1071/// Test router with a full-access key AND a read-only key.
1072///
1073/// Exercises the read-only credential branch in the auth middleware: a read-only
1074/// key authenticates safe (GET/HEAD/OPTIONS) requests but is rejected with 403 on
1075/// state-changing methods.
1076pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
1077    let mut state = test_app_state("sloc_test_readonly");
1078    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
1079    state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1080        readonly_key.to_owned(),
1081    ))]);
1082    build_router(state)
1083}
1084
1085/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
1086/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
1087/// preview restrictions.
1088pub fn make_test_router_server_mode() -> Router {
1089    let mut state = test_app_state("sloc_test_server");
1090    state.server_mode = true;
1091    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1092        TEST_SERVER_MODE_API_KEY.to_owned(),
1093    ))]);
1094    build_router(state)
1095}
1096
1097/// Server-mode test router with `allowed_scan_roots` configured.
1098///
1099/// Exercises the `validate_server_scan_path` allow/deny branches (in-root
1100/// success, unresolved path, and out-of-root rejection) that the empty-roots
1101/// router cannot reach.
1102pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
1103    let mut state = test_app_state("sloc_test_server_roots");
1104    state.server_mode = true;
1105    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1106        TEST_SERVER_MODE_API_KEY.to_owned(),
1107    ))]);
1108    state.base_config.discovery.allowed_scan_roots = roots;
1109    build_router(state)
1110}
1111
1112/// Test router where the analysis semaphore is pre-exhausted (0 permits).
1113/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
1114pub fn make_test_router_exhausted_semaphore() -> Router {
1115    let mut state = test_app_state("sloc_test_exhaust");
1116    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
1117    build_router(state)
1118}
1119
1120/// Test router with a very tight rate limit (3 req/min). The third request from
1121/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
1122pub fn make_test_router_tight_rate_limit() -> Router {
1123    let mut state = test_app_state("sloc_test_rate");
1124    state.rate_limiter = Arc::new(IpRateLimiter::new(
1125        Duration::from_mins(1),
1126        2,
1127        5,
1128        Duration::from_secs(5),
1129    ));
1130    build_router(state)
1131}
1132
1133/// Test router with a very tight auth lockout (threshold=2, window=200ms).
1134/// Used by tests that need to trigger and verify the auth lockout response.
1135pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1136    let mut state = test_app_state("sloc_test_auth_lockout");
1137    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1138    state.rate_limiter = Arc::new(IpRateLimiter::new(
1139        Duration::from_mins(1),
1140        600,
1141        2,                          // 2 failures triggers lockout
1142        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
1143    ));
1144    build_router(state)
1145}
1146
1147struct RuntimeSecurityConfig {
1148    api_keys: Vec<secrecy::SecretBox<String>>,
1149    readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1150    tls_cert: Option<String>,
1151    tls_key: Option<String>,
1152    tls_enabled: bool,
1153    trust_proxy: bool,
1154    trusted_proxy_ips: Vec<IpAddr>,
1155    rate_limiter: Arc<IpRateLimiter>,
1156}
1157
1158/// Whether the operator has explicitly opted into running server mode with no API key.
1159/// This is the single escape hatch for the fail-closed server-mode auth requirement.
1160fn allow_unauthenticated_server_mode() -> bool {
1161    matches!(
1162        std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1163        Ok("1" | "true" | "TRUE")
1164    )
1165}
1166
1167/// Fail-closed startup gate: refuse to launch a network-facing server that has no
1168/// authentication configured, unless the operator explicitly accepted the risk.
1169/// Desktop/local mode (`server_mode == false`) is always allowed.
1170fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1171    server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1172}
1173
1174/// Umbrella strict-posture switch (`SLOC_HARDENED=1`). When set, opt-in hardening
1175/// defaults take effect: transport encryption is required on non-loopback binds and
1176/// the auth-lockout threshold tightens. Off by default so existing deployments are
1177/// unaffected; individual controls also keep their own env overrides.
1178fn hardened_mode() -> bool {
1179    std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1180}
1181
1182/// Whether a certificate must be present before serving a network-facing
1183/// (non-loopback) bind. Opt-in via `SLOC_REQUIRE_TLS=1` or `SLOC_HARDENED=1`. Off by
1184/// default, so cleartext and reverse-proxy-terminated deployments keep working.
1185fn require_tls() -> bool {
1186    hardened_mode()
1187        || std::env::var("SLOC_REQUIRE_TLS")
1188            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1189}
1190
1191/// Optional sliding idle timeout for authenticated sessions. `None` (the default)
1192/// means only the 8-hour absolute cap applies — identical to prior behaviour.
1193/// `SLOC_SESSION_IDLE_SECS=<n>` sets an explicit idle limit (`0` disables); under
1194/// `SLOC_HARDENED` it defaults to 15 minutes. Each authenticated request refreshes
1195/// the session's last-seen time, so the window slides.
1196pub(crate) fn session_idle_timeout() -> Option<Duration> {
1197    match std::env::var("SLOC_SESSION_IDLE_SECS")
1198        .ok()
1199        .and_then(|v| v.parse::<u64>().ok())
1200    {
1201        Some(0) => None,
1202        Some(secs) => Some(Duration::from_secs(secs)),
1203        None if hardened_mode() => Some(Duration::from_mins(15)),
1204        None => None,
1205    }
1206}
1207
1208/// Generic authorized-use notice shown when a banner is required but the operator
1209/// has not supplied custom text via `SLOC_CONSENT_BANNER`.
1210const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1211Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1212are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1213
1214/// The pre-access consent banner text, if enabled. `SLOC_CONSENT_BANNER=<text>`
1215/// sets custom wording; `SLOC_HARDENED` alone falls back to a generic notice.
1216/// `None` (the default) disables the banner entirely.
1217fn consent_banner_text() -> Option<String> {
1218    if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1219        let t = t.trim();
1220        if !t.is_empty() {
1221            return Some(t.to_owned());
1222        }
1223    }
1224    hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1225}
1226
1227/// True when this request is a top-level browser navigation that the consent gate
1228/// should intercept. APIs, assets, webhooks, health checks, and the accept
1229/// endpoint itself are never gated.
1230fn consent_gate_applies(req: &Request<Body>) -> bool {
1231    const EXEMPT: &[&str] = &[
1232        "/auth/consent",
1233        "/static/",
1234        "/images/",
1235        "/assets/",
1236        "/badge/",
1237        "/healthz",
1238        "/api/",
1239        "/webhooks/",
1240        "/metrics",
1241        "/favicon",
1242        "/llms",
1243    ];
1244    if !matches!(
1245        *req.method(),
1246        axum::http::Method::GET | axum::http::Method::HEAD
1247    ) {
1248        return false;
1249    }
1250    let is_html = req
1251        .headers()
1252        .get(header::ACCEPT)
1253        .and_then(|v| v.to_str().ok())
1254        .is_some_and(|a| a.contains("text/html"));
1255    if !is_html {
1256        return false;
1257    }
1258    let path = req.uri().path();
1259    !EXEMPT.iter().any(|p| path.starts_with(p))
1260}
1261
1262/// Whether the request already carries the consent acknowledgement cookie.
1263fn request_has_consent(req: &Request<Body>) -> bool {
1264    req.headers()
1265        .get(header::COOKIE)
1266        .and_then(|v| v.to_str().ok())
1267        .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1268}
1269
1270/// Pre-access consent gate. When a banner is configured, browser page navigations
1271/// must acknowledge it (recorded in a session cookie) before proceeding. A no-op
1272/// when unconfigured, so default deployments are unaffected.
1273async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1274    let Some(text) = consent_banner_text() else {
1275        return next.run(req).await;
1276    };
1277    if !consent_gate_applies(&req) || request_has_consent(&req) {
1278        return next.run(req).await;
1279    }
1280    let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1281    render_consent_page(&text, next_path)
1282}
1283
1284/// Minimal escaping for embedding operator/config text into the banner HTML.
1285fn html_escape_consent(s: &str) -> String {
1286    s.replace('&', "&amp;")
1287        .replace('<', "&lt;")
1288        .replace('>', "&gt;")
1289        .replace('"', "&quot;")
1290}
1291
1292/// Render the consent interstitial with an "I Agree" action that records
1293/// acknowledgement and returns the user to where they were headed.
1294fn render_consent_page(text: &str, next_path: &str) -> Response {
1295    // Only accept a safe same-origin relative path as the return target.
1296    let safe_next = if next_path.starts_with('/')
1297        && !next_path.starts_with("//")
1298        && !next_path.contains("://")
1299        && !next_path.starts_with("/auth/")
1300    {
1301        next_path
1302    } else {
1303        "/"
1304    };
1305    let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1306    let body = format!(
1307        r#"<!doctype html><html><head><meta charset="utf-8">
1308<meta name="viewport" content="width=device-width, initial-scale=1">
1309<title>Notice and Consent — OxideSLOC</title>
1310<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1311h1{{color:#b85d33;font-size:20px}}.notice{{line-height:1.65;background:#f7efe7;border:1px solid #e2d2c2;border-radius:10px;padding:18px 20px;white-space:pre-wrap}}
1312.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1313.agree:hover{{background:#a04d27}}</style>
1314</head><body>
1315<h1>Notice and Consent</h1>
1316<div class="notice">{}</div>
1317<a class="agree" href="{}">I Agree</a>
1318</body></html>"#,
1319        html_escape_consent(text),
1320        accept_url
1321    );
1322    (StatusCode::OK, Html(body)).into_response()
1323}
1324
1325/// Emit operator-facing warnings for insecure server-mode configurations.
1326/// Pure side-effect (stdout); no bearing on the returned config values.
1327// The bools are independent configuration facts read from the resolved config, not
1328// a mode enum — folding them into a struct just to pass them here would add
1329// ceremony without clarity. Scope the allow to this diagnostic helper.
1330#[allow(clippy::fn_params_excessive_bools)]
1331fn emit_server_mode_warnings(
1332    server_mode: bool,
1333    api_keys_empty: bool,
1334    tls_enabled: bool,
1335    trust_proxy: bool,
1336    trusted_proxy_ips: &[IpAddr],
1337) {
1338    if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1339        // Absence of a key is a hard startup failure in server mode (enforced by the
1340        // caller, `serve`). The only exception is an explicit operator opt-in via
1341        // SLOC_ALLOW_UNAUTHENTICATED=1 for trusted-LAN testing — warn loudly then.
1342        println!(
1343            "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1344             authentication. Every web endpoint is publicly reachable. Do NOT use this \
1345             outside a trusted, isolated network."
1346        );
1347    }
1348    if server_mode && !tls_enabled {
1349        println!(
1350            "WARNING: TLS is not configured. Traffic is cleartext. \
1351             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1352             or terminate TLS at a reverse proxy (nginx, caddy)."
1353        );
1354    }
1355    if server_mode {
1356        println!(
1357            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1358             to restrict cross-origin access (comma-separated)."
1359        );
1360    }
1361    emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1362    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1363        println!(
1364            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1365             DISABLED for all git operations. Remove this variable before production use."
1366        );
1367    }
1368}
1369
1370/// Emit the reverse-proxy / X-Forwarded-For trust advisory for server mode.
1371fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1372    if trust_proxy {
1373        if trusted_proxy_ips.is_empty() {
1374            println!(
1375                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1376                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1377                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1378            );
1379        } else {
1380            println!(
1381                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1382                trusted_proxy_ips
1383                    .iter()
1384                    .map(std::string::ToString::to_string)
1385                    .collect::<Vec<_>>()
1386                    .join(", ")
1387            );
1388        }
1389    } else if server_mode {
1390        println!(
1391            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1392             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1393             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1394             enable per-client rate limiting via X-Forwarded-For."
1395        );
1396    }
1397}
1398
1399fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1400    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1401        .or_else(|_| std::env::var("SLOC_API_KEY"))
1402        .unwrap_or_default()
1403        .split(',')
1404        .map(str::trim)
1405        .filter(|s| !s.is_empty())
1406        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1407        .collect();
1408    let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1409        std::env::var("SLOC_API_KEYS_READONLY")
1410            .unwrap_or_default()
1411            .split(',')
1412            .map(str::trim)
1413            .filter(|s| !s.is_empty())
1414            .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1415            .collect();
1416    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1417    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1418    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1419    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1420    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1421        .unwrap_or_default()
1422        .split(',')
1423        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1424        .collect();
1425    emit_server_mode_warnings(
1426        server_mode,
1427        api_keys.is_empty(),
1428        tls_enabled,
1429        trust_proxy,
1430        &trusted_proxy_ips,
1431    );
1432    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1433        .ok()
1434        .and_then(|v| v.parse::<u32>().ok())
1435        .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1436    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1437        .ok()
1438        .and_then(|v| v.parse::<u64>().ok())
1439        .unwrap_or(3600);
1440    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1441    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1442    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1443    let default_rpm: usize = if server_mode { 120 } else { 600 };
1444    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1445        .ok()
1446        .and_then(|v| v.parse::<usize>().ok())
1447        .unwrap_or(default_rpm);
1448    let rate_limiter = Arc::new(IpRateLimiter::new(
1449        Duration::from_mins(1),
1450        rate_limit_rpm,
1451        auth_lockout_threshold,
1452        Duration::from_secs(auth_lockout_secs),
1453    ));
1454    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1455    RuntimeSecurityConfig {
1456        api_keys,
1457        readonly_api_keys,
1458        tls_cert,
1459        tls_key,
1460        tls_enabled,
1461        trust_proxy,
1462        trusted_proxy_ips,
1463        rate_limiter,
1464    }
1465}
1466
1467/// # Errors
1468///
1469/// Returns an error if the server fails to bind to the configured address or
1470/// if the TLS configuration cannot be loaded.
1471///
1472/// # Panics
1473///
1474/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1475#[allow(clippy::too_many_lines)]
1476pub async fn serve(config: AppConfig) -> Result<()> {
1477    // Anchor the uptime clock at launch so /api/health reports true process uptime.
1478    process_start();
1479    let bind_address = config.web.bind_address.clone();
1480    let server_mode = config.web.server_mode;
1481    let output_root = resolve_output_root(None);
1482    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1483    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1484        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1485    let mut registry = ScanRegistry::load(&registry_path);
1486    registry.prune_stale();
1487    let _ = registry.save(&registry_path);
1488
1489    let sec = load_runtime_security_config(server_mode);
1490    // Security posture: refuse to start an unauthenticated network-facing server. A server-mode
1491    // launch with no API key would expose every endpoint publicly; fail closed unless the
1492    // operator has explicitly accepted the risk via SLOC_ALLOW_UNAUTHENTICATED=1.
1493    if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1494        audit::record(
1495            "server_start_refused",
1496            "denied",
1497            &[(
1498                "reason",
1499                "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1500            )],
1501        );
1502        anyhow::bail!(
1503            "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1504             (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1505             unauthenticated server on a trusted, isolated network, explicitly set \
1506             SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1507        );
1508    }
1509    if server_mode && sec.api_keys.is_empty() {
1510        audit::record("server_start_unauthenticated", "warning", &[]);
1511    }
1512    spawn_upload_staging_cleanup();
1513
1514    let git_clones_dir = resolve_git_clones_dir(&output_root);
1515    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1516        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1517    let schedules = ScheduleStore::load(&schedules_path);
1518    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1519        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1520    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1521    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1522        |_| output_root.join("confluence_config.json"),
1523        PathBuf::from,
1524    );
1525    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1526    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1527        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1528    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1529    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1530        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1531    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1532
1533    let state = AppState {
1534        base_config: config,
1535        artifacts: Arc::new(Mutex::new(HashMap::new())),
1536        async_runs: Arc::new(Mutex::new(HashMap::new())),
1537        registry: Arc::new(Mutex::new(registry)),
1538        registry_path,
1539        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1540        server_mode,
1541        allow_unauthenticated: allow_unauthenticated_server_mode(),
1542        tls_enabled: sec.tls_enabled,
1543        api_keys: Arc::new(sec.api_keys),
1544        readonly_api_keys: Arc::new(sec.readonly_api_keys),
1545        rate_limiter: sec.rate_limiter,
1546        trust_proxy: sec.trust_proxy,
1547        trusted_proxy_ips: sec.trusted_proxy_ips,
1548        git_clones_dir,
1549        schedules: Arc::new(Mutex::new(schedules)),
1550        schedules_path,
1551        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1552        scan_profiles_path,
1553        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1554        confluence: Arc::new(Mutex::new(confluence)),
1555        confluence_path,
1556        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1557        watched_dirs_path,
1558        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1559        cleanup_policy_path,
1560        cleanup_task_handle: Arc::new(Mutex::new(None)),
1561    };
1562
1563    restart_poll_schedules(&state).await;
1564    warn_insecure_gitlab_webhooks(&state).await;
1565
1566    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1567    {
1568        let enabled = state
1569            .cleanup_policy
1570            .lock()
1571            .await
1572            .policy
1573            .as_ref()
1574            .is_some_and(|p| p.enabled);
1575        if enabled {
1576            let handle = spawn_cleanup_policy_task(state.clone());
1577            *state.cleanup_task_handle.lock().await = Some(handle);
1578        }
1579    }
1580
1581    let app = build_router(state.clone());
1582
1583    // Try the configured port first, then step up through a few alternatives.
1584    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1585    // kernel zombie (visible in netstat but owned by no living process).  Rather
1586    // than failing, we auto-select the next free port and tell the user.
1587    let preferred: SocketAddr = bind_address
1588        .parse()
1589        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1590
1591    // Opt-in transport-encryption gate: refuse to expose a network-facing (non-
1592    // loopback) listener in cleartext when TLS enforcement is requested. Off by
1593    // default; enable with SLOC_REQUIRE_TLS=1 or SLOC_HARDENED=1. Loopback binds
1594    // (including reverse-proxy-terminated setups) are always allowed.
1595    if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1596        audit::record(
1597            "server_start_refused",
1598            "denied",
1599            &[("reason", "TLS required for non-loopback bind")],
1600        );
1601        anyhow::bail!(
1602            "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1603             SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1604             a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1605        );
1606    }
1607
1608    let (listener, addr) = {
1609        let candidates = (0u16..=9).map(|offset| {
1610            let mut a = preferred;
1611            a.set_port(preferred.port().saturating_add(offset));
1612            a
1613        });
1614        let mut found = None;
1615        for candidate in candidates {
1616            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1617                found = Some((l, candidate));
1618                break;
1619            }
1620        }
1621        found.ok_or_else(|| {
1622            anyhow::anyhow!(
1623                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1624                bind_address,
1625                preferred.port(),
1626                preferred.port().saturating_add(9)
1627            )
1628        })?
1629    };
1630    if addr != preferred {
1631        eprintln!(
1632            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1633             using {} instead.",
1634            preferred.port(),
1635            addr.port()
1636        );
1637    }
1638
1639    if sec.tls_enabled {
1640        let cert_path = sec
1641            .tls_cert
1642            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1643        let key_path = sec
1644            .tls_key
1645            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1646        let tls_config = build_tls_config(&cert_path, &key_path)
1647            .context("failed to load TLS certificate/key")?;
1648        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1649
1650        let url = format!("https://{addr}/");
1651        println!("OxideSLOC server running at {url} (TLS)");
1652        if let Some(lan) = wildcard_lan_url(&url) {
1653            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1654        }
1655        println!("Use Ctrl+C to stop.");
1656
1657        return serve_tls(listener, app, acceptor, server_mode).await;
1658    }
1659
1660    let url = format!("http://{addr}/");
1661    log_startup_url(&url, server_mode);
1662
1663    axum::serve(
1664        listener,
1665        app.into_make_service_with_connect_info::<SocketAddr>(),
1666    )
1667    .with_graceful_shutdown(shutdown_signal(server_mode))
1668    .await
1669    .context("web server terminated unexpectedly")
1670}
1671
1672/// Discover the primary non-loopback IPv4 address by asking the OS which
1673/// outbound interface it would use to reach a public address.  No packets are
1674/// sent — the UDP socket is only used to query the routing table.
1675fn primary_lan_ip() -> Option<String> {
1676    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1677    socket.connect("8.8.8.8:80").ok()?;
1678    let addr = socket.local_addr().ok()?;
1679    let ip = addr.ip();
1680    if ip.is_loopback() {
1681        return None;
1682    }
1683    Some(ip.to_string())
1684}
1685
1686/// If `url` binds a wildcard address (`0.0.0.0` or `[::]`), return the same URL
1687/// with the primary LAN IP substituted, so the startup log shows a client-usable
1688/// address alongside the bind address. Returns `None` for concrete binds or when
1689/// no routable LAN address can be determined (e.g. loopback-only / no default route).
1690fn wildcard_lan_url(url: &str) -> Option<String> {
1691    if url.contains("0.0.0.0") {
1692        primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1693    } else if url.contains("[::]") {
1694        primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1695    } else {
1696        None
1697    }
1698}
1699
1700/// Print the startup URL and, in local mode, open the browser and schedule it.
1701fn log_startup_url(url: &str, server_mode: bool) {
1702    if server_mode {
1703        println!("OxideSLOC server running at {url}");
1704        if let Some(lan) = wildcard_lan_url(url) {
1705            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1706        }
1707        println!("Use Ctrl+C to stop.");
1708    } else {
1709        println!("OxideSLOC local web UI running at {url}");
1710        println!("Press Ctrl+C to stop the server.");
1711        let open_url = url.to_owned();
1712        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1713    }
1714}
1715
1716/// Open the given URL in the default system browser.
1717fn open_browser_tab(url: &str) {
1718    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1719    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1720    // first quoted token as a window title — both are fragile and shell-parsed. The
1721    // url.dll handler receives the URL as a single, non-shell argument.
1722    #[cfg(target_os = "windows")]
1723    let _ = std::process::Command::new("rundll32")
1724        .args(["url.dll,FileProtocolHandler", url])
1725        .stdout(Stdio::null())
1726        .stderr(Stdio::null())
1727        .spawn();
1728    #[cfg(target_os = "macos")]
1729    let _ = std::process::Command::new("open")
1730        .arg(url)
1731        .stdout(Stdio::null())
1732        .stderr(Stdio::null())
1733        .spawn();
1734    #[cfg(target_os = "linux")]
1735    let _ = std::process::Command::new("xdg-open")
1736        .arg(url)
1737        .stdout(Stdio::null())
1738        .stderr(Stdio::null())
1739        .spawn();
1740}
1741
1742/// Graceful-shutdown future: resolves on Ctrl-C.
1743async fn shutdown_signal(server_mode: bool) {
1744    if tokio::signal::ctrl_c().await.is_ok() {
1745        println!();
1746        if server_mode {
1747            println!("Shutting down OxideSLOC server...");
1748        } else {
1749            println!("Shutting down OxideSLOC local web UI...");
1750        }
1751        println!("Server stopped cleanly.");
1752    }
1753}
1754
1755/// Load a rustls `ServerConfig` from PEM certificate and key files.
1756fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1757    use rustls_pki_types::pem::PemObject;
1758    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1759
1760    let cert_bytes =
1761        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1762    let key_bytes =
1763        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1764
1765    let cert_chain: Vec<CertificateDer<'static>> =
1766        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1767            .collect::<std::result::Result<_, _>>()
1768            .context("failed to parse TLS certificates")?;
1769
1770    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1771        .context("failed to parse TLS private key")?;
1772
1773    // Explicitly pin the accepted protocol versions to TLS 1.2 and 1.3 (these are
1774    // rustls's safe defaults; stated here so the accepted set is auditable). rustls
1775    // ships only modern AEAD cipher suites — no CBC/RC4/3DES — so no suite pinning is
1776    // needed to exclude weak ciphers.
1777    let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1778        &rustls::version::TLS13,
1779        &rustls::version::TLS12,
1780    ]);
1781
1782    // Opt-in mutual TLS: when SLOC_TLS_CLIENT_CA points to a PEM CA bundle, require
1783    // every client to present a certificate that chains to it — a transport-layer
1784    // factor on top of the application API key. Unset = no client auth (prior
1785    // behaviour).
1786    let config = match client_cert_verifier()? {
1787        Some(verifier) => builder
1788            .with_client_cert_verifier(verifier)
1789            .with_single_cert(cert_chain, key),
1790        None => builder
1791            .with_no_client_auth()
1792            .with_single_cert(cert_chain, key),
1793    };
1794    config.context("failed to build TLS server config")
1795}
1796
1797/// Build a client-certificate verifier when `SLOC_TLS_CLIENT_CA` is configured,
1798/// enabling mutual TLS. Returns `None` (no client auth) when unset — the default.
1799fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1800    use rustls_pki_types::CertificateDer;
1801    use rustls_pki_types::pem::PemObject;
1802
1803    let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1804        .ok()
1805        .filter(|s| !s.is_empty())
1806    else {
1807        return Ok(None);
1808    };
1809    let ca_bytes = fs::read(&ca_path)
1810        .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1811    let mut roots = rustls::RootCertStore::empty();
1812    for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1813        let cert = cert.context("failed to parse client CA certificate")?;
1814        roots
1815            .add(cert)
1816            .context("failed to add client CA certificate to root store")?;
1817    }
1818    let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1819        .build()
1820        .context("failed to build client certificate verifier")?;
1821    Ok(Some(verifier))
1822}
1823
1824/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1825async fn serve_tls(
1826    listener: tokio::net::TcpListener,
1827    app: Router,
1828    acceptor: tokio_rustls::TlsAcceptor,
1829    server_mode: bool,
1830) -> Result<()> {
1831    use hyper_util::rt::{TokioExecutor, TokioIo};
1832    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1833    use hyper_util::service::TowerToHyperService;
1834    use tower::{Service, ServiceExt};
1835
1836    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1837
1838    loop {
1839        tokio::select! {
1840            biased;
1841            _ = tokio::signal::ctrl_c() => {
1842                println!();
1843                if server_mode {
1844                    println!("Shutting down OxideSLOC server...");
1845                } else {
1846                    println!("Shutting down OxideSLOC local web UI...");
1847                }
1848                println!("Server stopped cleanly.");
1849                return Ok(());
1850            }
1851            result = listener.accept() => {
1852                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1853                let acceptor = acceptor.clone();
1854                let mut factory = make_svc.clone();
1855
1856                tokio::spawn(async move {
1857                    let tls = match acceptor.accept(tcp).await {
1858                        Ok(s) => s,
1859                        Err(e) => {
1860                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1861                            return;
1862                        }
1863                    };
1864                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1865                        Ok(f) => match Service::call(f, peer_addr).await {
1866                            Ok(s) => s,
1867                            Err(_) => return,
1868                        },
1869                        Err(_) => return,
1870                    };
1871                    let io = TokioIo::new(tls);
1872                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1873                        .serve_connection(io, TowerToHyperService::new(svc))
1874                        .await
1875                    {
1876                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1877                    }
1878                });
1879            }
1880        }
1881    }
1882}
1883
1884// auth moved to auth.rs
1885
1886fn build_cors_layer(server_mode: bool) -> CorsLayer {
1887    if server_mode {
1888        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1889            .unwrap_or_default()
1890            .split(',')
1891            .filter(|s| !s.is_empty())
1892            .filter_map(|s| s.trim().parse().ok())
1893            .collect();
1894        if allowed.is_empty() {
1895            return CorsLayer::new();
1896        }
1897        CorsLayer::new()
1898            .allow_origin(AllowOrigin::list(allowed))
1899            .allow_methods(AllowMethods::list([
1900                axum::http::Method::GET,
1901                axum::http::Method::POST,
1902            ]))
1903            .allow_headers(AllowHeaders::list([
1904                axum::http::header::AUTHORIZATION,
1905                axum::http::header::CONTENT_TYPE,
1906            ]))
1907    } else {
1908        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1909            let s = origin.to_str().unwrap_or("");
1910            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1911        }))
1912    }
1913}
1914
1915async fn add_security_headers(
1916    State(state): State<AppState>,
1917    mut req: Request<Body>,
1918    next: Next,
1919) -> Response {
1920    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1921    req.extensions_mut().insert(CspNonce(nonce.clone()));
1922    let mut resp = next.run(req).await;
1923    inject_page_fade_into_html(&mut resp, &nonce).await;
1924    let h = resp.headers_mut();
1925    // frame-ancestors defaults to deny (the UI cannot be iframed anywhere). An
1926    // operator can opt into embedding in named corporate dashboards by setting
1927    // SLOC_FRAME_ANCESTORS to a space-separated origin allowlist. X-Frame-Options
1928    // cannot express a multi-origin allowlist, so when one is configured we drop
1929    // XFO and let the CSP frame-ancestors directive govern (per-origin, and what
1930    // modern browsers honour); unset keeps the strict XFO: DENY + frame-ancestors
1931    // 'none' posture. A malformed value falls back to the safe default below.
1932    let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1933        .ok()
1934        .map(|v| v.trim().to_string())
1935        .filter(|v| !v.is_empty());
1936    if frame_ancestors.is_none() {
1937        h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1938    }
1939    let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1940    h.insert(
1941        "X-Content-Type-Options",
1942        HeaderValue::from_static("nosniff"),
1943    );
1944    h.insert(
1945        "Referrer-Policy",
1946        HeaderValue::from_static("strict-origin-when-cross-origin"),
1947    );
1948    let csp = format!(
1949        "default-src 'self'; \
1950         base-uri 'self'; \
1951         form-action 'self'; \
1952         style-src 'self' 'unsafe-inline'; \
1953         img-src 'self' data: blob:; \
1954         script-src 'self' 'nonce-{nonce}'; \
1955         font-src 'self' data:; \
1956         object-src 'none'; \
1957         frame-ancestors {frame_ancestors_directive}"
1958    );
1959    h.insert(
1960        "Content-Security-Policy",
1961        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1962            HeaderValue::from_static(
1963                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1964            )
1965        }),
1966    );
1967    h.insert(
1968        "X-Permitted-Cross-Domain-Policies",
1969        HeaderValue::from_static("none"),
1970    );
1971    h.insert(
1972        "Permissions-Policy",
1973        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1974    );
1975    h.insert(
1976        "Cross-Origin-Opener-Policy",
1977        HeaderValue::from_static("same-origin"),
1978    );
1979    h.insert(
1980        "Cross-Origin-Resource-Policy",
1981        HeaderValue::from_static("same-origin"),
1982    );
1983    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1984    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1985    h.insert(
1986        "Cross-Origin-Embedder-Policy",
1987        HeaderValue::from_static("require-corp"),
1988    );
1989    if state.tls_enabled {
1990        h.insert(
1991            "Strict-Transport-Security",
1992            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1993        );
1994    }
1995    resp
1996}
1997
1998/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1999///
2000/// On state-changing methods, browser-driven cookie-authenticated requests must
2001/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
2002/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
2003///
2004/// Deliberately exempt:
2005/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
2006/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
2007///   ambient, so it is not CSRF-exploitable.
2008/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
2009/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
2010///   a browser performing a CSRF attack always sends `Origin`.
2011async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
2012    use axum::http::Method;
2013
2014    let is_state_changing = matches!(
2015        *req.method(),
2016        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
2017    );
2018    let path = req.uri().path();
2019    let has_token_auth = req.headers().contains_key("X-API-Key")
2020        || req
2021            .headers()
2022            .get(header::AUTHORIZATION)
2023            .and_then(|v| v.to_str().ok())
2024            .is_some_and(|v| v.starts_with("Bearer "));
2025
2026    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
2027        return next.run(req).await;
2028    }
2029
2030    let headers = req.headers();
2031    let header_str = |name: &header::HeaderName| {
2032        headers
2033            .get(name)
2034            .and_then(|v| v.to_str().ok())
2035            .map(str::to_owned)
2036    };
2037    let origin = header_str(&header::ORIGIN);
2038    let referer = header_str(&header::REFERER);
2039    let host = header_str(&header::HOST);
2040
2041    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
2042    let authority_of = |url: &str| -> Option<String> {
2043        url.split_once("://")
2044            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
2045    };
2046
2047    let source_authority = origin
2048        .as_deref()
2049        .and_then(authority_of)
2050        .or_else(|| referer.as_deref().and_then(authority_of));
2051
2052    match (source_authority, host) {
2053        // Neither Origin nor Referer present: treat as a non-browser client.
2054        (None, _) => next.run(req).await,
2055        (Some(src), Some(h)) if src == h => next.run(req).await,
2056        (Some(src), host) => {
2057            tracing::warn!(
2058                event = "csrf_rejected",
2059                path = %path,
2060                origin = %src,
2061                host = ?host,
2062                "Cross-origin state-changing request rejected (CSRF guard)"
2063            );
2064            (
2065                StatusCode::FORBIDDEN,
2066                "403 Forbidden — cross-origin request rejected\n",
2067            )
2068                .into_response()
2069        }
2070    }
2071}
2072
2073/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
2074/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
2075/// is overkill — a short opacity fade gives a smooth page-to-page transition
2076/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
2077/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
2078/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
2079/// light-mode flash for dark-theme users.
2080fn page_fade_html(nonce: &str) -> String {
2081    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
2082    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
2083    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
2084    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
2085    // class) keeps it invisible from the moment this style parses — at the top of <body> —
2086    // through the entire body parse, which reads as a delay before navigation "begins"
2087    // and then a blink. Without a fill-mode the animation starts at first paint and plays
2088    // 0 -> 1 cleanly, with no pre-paint hold.
2089    const STYLE: &str = r"<style>
2090@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
2091.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
2092body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
2093@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
2094</style>";
2095    // `dark`: apply the saved dark theme before paint to avoid a light flash.
2096    // The click handler gives immediate feedback by fading the *content* out the moment a
2097    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
2098    // preventDefault or delay navigation — the browser navigates instantly and the fade
2099    // plays opportunistically during the natural fetch window, so no latency is added.
2100    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
2101    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
2102    // if the click was actually a download (no unload) or the page is restored from bfcache.
2103    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');});})();";
2104    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
2105}
2106
2107/// Self-contained branded loading overlay for the heavy comparison pages (Scan
2108/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
2109/// `<script>` — meant to be spliced in immediately after `<body>`.
2110///
2111/// It pairs the spinner with a **visibility gate**: from the first byte the page
2112/// content is held at `visibility:hidden` (only the overlay paints), so the user
2113/// never sees a half-rendered flash while charts/tables are still settling. On
2114/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
2115/// still-opaque overlay, which then fades out one frame later — so the reveal is
2116/// of a finished page, with no glitch on either side of the transition.
2117///
2118/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
2119/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
2120/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
2121fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
2122    const TPL: &str = r#"<style nonce="__N__">
2123html.sloc-pending body{visibility:hidden;}
2124html.sloc-pending #rpt-loading-overlay{visibility:visible;}
2125#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%);}
2126#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
2127body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
2128body.pdf-mode #rpt-loading-overlay{display:none!important;}
2129.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
2130.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;}
2131.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;}
2132@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
2133@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
2134body.dark-theme .rpt-bg-blob{opacity:.36;}
2135.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;}
2136@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
2137body.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);}
2138.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
2139.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2140.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2141.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));}
2142@keyframes rpt-spin{to{transform:rotate(360deg);}}
2143.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;}
2144body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2145body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2146.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2147.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;}
2148@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2149.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2150.rpt-dot:nth-child(2){animation-delay:.28s;}
2151.rpt-dot:nth-child(3){animation-delay:.56s;}
2152@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2153.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2154.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2155.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;}
2156body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2157@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;}}
2158</style>
2159<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2160<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>
2161<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2162  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2163  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2164  <div class="rpt-load-card">
2165    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2166    <div class="rpt-spinner-wrap">
2167      <div class="rpt-spinner-track"></div>
2168      <div class="rpt-spinner"></div>
2169      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2170    </div>
2171    <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>
2172    <div class="rpt-status" id="rpt-status">__LABEL__</div>
2173    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2174  </div>
2175</div>
2176<script nonce="__N__">
2177(function(){
2178  var ov=document.getElementById('rpt-loading-overlay');
2179  var root=document.documentElement;
2180  function reveal(){root.classList.remove('sloc-pending');}
2181  if(!ov){reveal();return;}
2182  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2183  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2184  var mi=0,prog=0,done=false,start=Date.now();
2185  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2186  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2187  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2188  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2189  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2190  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2191  setProg(8);
2192  var msgTimer=setInterval(nextMsg,700);
2193  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);
2194  // These pages draw charts into known SVG containers that start empty and are
2195  // filled by JS once layout is available (some only after a ResizeObserver pass
2196  // post-`load`). Treat the page as ready only once every chart container present
2197  // actually has rendered content, so the overlay never lifts on a half-drawn page.
2198  function chartsRendered(){
2199    var sel=['#cmp-tl-svg','#mc-chart'];
2200    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2201    return true;
2202  }
2203  function finish(){
2204    if(done)return;done=true;
2205    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2206    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2207    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2208    reveal();
2209    requestAnimationFrame(function(){requestAnimationFrame(function(){
2210      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2211    });});
2212  }
2213  // Wait for `load` (resources + first layout), then poll until the charts have
2214  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2215  function afterLoad(){
2216    var loadAt=Date.now();
2217    (function poll(){
2218      if(done)return;
2219      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2220        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2221        return;
2222      }
2223      requestAnimationFrame(poll);
2224    })();
2225  }
2226  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2227  // Absolute safety net: never let the gate/overlay get stuck.
2228  setTimeout(function(){if(!done)finish();},HARD_CAP);
2229})();
2230</script>"#;
2231    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2232}
2233
2234/// Shared toast-notification assets + a global PDF-export helper, spliced into
2235/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
2236/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
2237/// placed just before `</body>`.
2238///
2239/// It defines two globals:
2240/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
2241///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
2242///   toast stays up until its returned handle's `.dismiss()` is called.
2243/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
2244///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
2245///   `/export/pdf`, triggers the download, then raises a success or error toast and
2246///   restores the button. Centralising this guarantees identical, obvious feedback
2247///   everywhere instead of a silent `alert()`-only failure path.
2248fn sloc_toast_assets(nonce: &str) -> String {
2249    const TPL: &str = r#"<style nonce="__N__">
2250#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;}
2251.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);}
2252.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2253.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2254.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;}
2255.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2256.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2257.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2258.sloc-toast-success{border-color:#bfe0cc;}
2259.sloc-toast-error{border-color:#e6b3b3;}
2260.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2261.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;}
2262@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2263.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;}
2264.sloc-toast-x:hover{opacity:1;}
2265body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2266body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2267body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2268body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2269@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2270</style>
2271<script nonce="__N__">
2272(function(){
2273  if(window.slocToast)return;
2274  function wrap(){
2275    var w=document.getElementById('sloc-toast-wrap');
2276    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);}
2277    return w;
2278  }
2279  window.slocToast=function(msg,opts){
2280    opts=opts||{};
2281    var type=opts.type||'info';
2282    var loading=type==='loading';
2283    var t=document.createElement('div');
2284    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2285    t.setAttribute('role',type==='error'?'alert':'status');
2286    var ico=loading
2287      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2288      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2289    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2290    t.querySelector('.sloc-toast-msg').textContent=String(msg);
2291    wrap().appendChild(t);
2292    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2293    var gone=false,timer=null;
2294    function close(){
2295      if(gone)return;gone=true;if(timer)clearTimeout(timer);
2296      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2297      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2298    }
2299    t.querySelector('.sloc-toast-x').addEventListener('click',close);
2300    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2301    if(ttl>0)timer=setTimeout(close,ttl);
2302    return {dismiss:close,el:t};
2303  };
2304  window.slocExportPdf=function(o){
2305    o=o||{};
2306    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2307    if(btn&&btn.disabled)return;
2308    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2309    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2310    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2311      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2312      .then(function(blob){
2313        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2314        document.body.appendChild(a);a.click();document.body.removeChild(a);
2315        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2316        load.dismiss();
2317        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2318      })
2319      .catch(function(e){
2320        load.dismiss();
2321        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2322      })
2323      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2324  };
2325})();
2326</script>"#;
2327    TPL.replace("__N__", nonce)
2328}
2329
2330/// Buffer an HTML response body and splice the page fade-in right after the
2331/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
2332/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
2333/// branded loading spinner for slow renders).
2334async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2335    let is_html = resp
2336        .headers()
2337        .get(header::CONTENT_TYPE)
2338        .and_then(|v| v.to_str().ok())
2339        .is_some_and(|v| v.starts_with("text/html"));
2340    if !is_html {
2341        return;
2342    }
2343    let body = std::mem::replace(resp.body_mut(), Body::empty());
2344    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2345        return;
2346    };
2347    let html = match String::from_utf8(bytes.to_vec()) {
2348        Ok(s) => s,
2349        Err(e) => {
2350            *resp.body_mut() = Body::from(e.into_bytes());
2351            return;
2352        }
2353    };
2354    if html.contains("id=\"rpt-loading-overlay\"") {
2355        *resp.body_mut() = Body::from(html);
2356        return;
2357    }
2358    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
2359    // avoids allocating a lowercased copy of the whole document on every request.
2360    // Fall back to a case-insensitive scan only if that fails (rare/never).
2361    let insert_at = html
2362        .find("<body")
2363        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2364        .or_else(|| {
2365            let lower = html.to_ascii_lowercase();
2366            lower
2367                .find("<body")
2368                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2369        });
2370    let new_html = match insert_at {
2371        Some(at) => {
2372            let mut out = String::with_capacity(html.len() + 1024);
2373            out.push_str(&html[..at]);
2374            out.push_str(&page_fade_html(nonce));
2375            out.push_str(&html[at..]);
2376            out
2377        }
2378        None => html,
2379    };
2380    resp.headers_mut().remove(header::CONTENT_LENGTH);
2381    *resp.body_mut() = Body::from(new_html);
2382}
2383
2384async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2385    let peer_ip = req
2386        .extensions()
2387        .get::<axum::extract::ConnectInfo<SocketAddr>>()
2388        .map(|c| c.0.ip());
2389
2390    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
2391    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
2392    // header spoofing from direct connections.
2393    let ip = peer_ip
2394        .and_then(|peer| {
2395            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2396                req.headers()
2397                    .get("X-Forwarded-For")
2398                    .and_then(|v| v.to_str().ok())
2399                    .and_then(|s| s.split(',').next())
2400                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
2401            } else {
2402                None
2403            }
2404        })
2405        .or(peer_ip)
2406        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2407
2408    if !state.rate_limiter.is_allowed(ip) {
2409        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2410            path = %req.uri().path(), "Rate limit exceeded");
2411        return (
2412            StatusCode::TOO_MANY_REQUESTS,
2413            [(header::RETRY_AFTER, "60")],
2414            "429 Too Many Requests\n",
2415        )
2416            .into_response();
2417    }
2418    next.run(req).await
2419}
2420
2421async fn splash(
2422    State(state): State<AppState>,
2423    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2424) -> impl IntoResponse {
2425    let lan_ip = if state.server_mode {
2426        primary_lan_ip()
2427    } else {
2428        None
2429    };
2430    let port = state
2431        .base_config
2432        .web
2433        .bind_address
2434        .rsplit(':')
2435        .next()
2436        .and_then(|p| p.parse::<u16>().ok())
2437        .unwrap_or(4317);
2438    let has_api_key = !state.api_keys.is_empty();
2439    let template = SplashTemplate {
2440        csp_nonce,
2441        server_mode: state.server_mode,
2442        lan_ip,
2443        port,
2444        version: env!("CARGO_PKG_VERSION"),
2445        has_api_key,
2446    };
2447    Html(
2448        template
2449            .render()
2450            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2451    )
2452}
2453
2454async fn index(
2455    State(state): State<AppState>,
2456    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2457    Query(query): Query<IndexQuery>,
2458) -> impl IntoResponse {
2459    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2460        let policy = query
2461            .mixed_line_policy
2462            .unwrap_or_else(|| "code_only".to_string());
2463        let behavior = query
2464            .binary_file_behavior
2465            .unwrap_or_else(|| "skip".to_string());
2466        let cfg = ScanConfig {
2467            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2468            path: query.path.unwrap_or_default(),
2469            include_globs: query.include_globs.unwrap_or_default(),
2470            exclude_globs: query.exclude_globs.unwrap_or_default(),
2471            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2472            mixed_line_policy: policy,
2473            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2474                != Some("off"),
2475            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2476            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2477            vendor_directory_detection: query.vendor_directory_detection.as_deref()
2478                != Some("disabled"),
2479            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2480            binary_file_behavior: behavior,
2481            output_dir: query.output_dir.unwrap_or_default(),
2482            report_title: query.report_title.unwrap_or_default(),
2483            continuation_line_policy: query
2484                .continuation_line_policy
2485                .unwrap_or_else(default_each_physical_line),
2486            blank_in_block_comment_policy: query
2487                .blank_in_block_comment_policy
2488                .unwrap_or_else(default_count_as_comment),
2489            count_compiler_directives: query.count_compiler_directives.as_deref()
2490                != Some("disabled"),
2491            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2492            style_col_threshold: query
2493                .style_col_threshold
2494                .as_deref()
2495                .and_then(|s| s.parse().ok())
2496                .unwrap_or(80),
2497            style_score_threshold: query
2498                .style_score_threshold
2499                .as_deref()
2500                .and_then(|s| s.parse().ok())
2501                .unwrap_or(0),
2502            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2503            coverage_file: query.coverage_file.unwrap_or_default(),
2504            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2505            complexity_alert: query
2506                .complexity_alert
2507                .as_deref()
2508                .and_then(|s| s.parse().ok())
2509                .unwrap_or(0),
2510            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2511            activity_window: query
2512                .activity_window
2513                .as_deref()
2514                .and_then(|s| s.parse().ok())
2515                .unwrap_or(90),
2516        };
2517        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2518    } else {
2519        "{}".to_string()
2520    };
2521
2522    let git_repo = query.git_repo.unwrap_or_default();
2523    let git_ref = query.git_ref.unwrap_or_default();
2524
2525    let git_label = make_git_label(&git_repo, &git_ref);
2526    let git_output_dir = if git_label.is_empty() {
2527        String::new()
2528    } else {
2529        desktop_dir().join(&git_label).display().to_string()
2530    };
2531    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2532    let git_output_dir_json =
2533        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2534
2535    let template = IndexTemplate {
2536        version: env!("CARGO_PKG_VERSION"),
2537        prefill_json,
2538        csp_nonce,
2539        git_repo,
2540        git_ref,
2541        git_label_json,
2542        git_output_dir_json,
2543        server_mode: state.server_mode,
2544    };
2545
2546    Html(
2547        template
2548            .render()
2549            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2550    )
2551}
2552
2553async fn scan_setup_handler(
2554    State(state): State<AppState>,
2555    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2556) -> impl IntoResponse {
2557    let recent_scans_json = {
2558        let arr: Vec<serde_json::Value> = {
2559            let reg = state.registry.lock().await;
2560            reg.entries
2561                .iter()
2562                .rev()
2563                .take(6)
2564                .map(|e| {
2565                    let run_dir = e
2566                        .html_path
2567                        .as_ref()
2568                        .or(e.json_path.as_ref())
2569                        .and_then(|p| p.parent().map(PathBuf::from));
2570                    let config_val: Option<serde_json::Value> = run_dir
2571                        .and_then(|d| find_scan_config_in_dir(&d))
2572                        .and_then(|p| fs::read_to_string(&p).ok())
2573                        .and_then(|s| serde_json::from_str(&s).ok());
2574                    serde_json::json!({
2575                        "project_label": e.project_label,
2576                        "timestamp": fmt_la_time(e.timestamp_utc),
2577                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2578                        "config": config_val,
2579                    })
2580                })
2581                .collect()
2582        };
2583        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2584    };
2585
2586    let template = ScanSetupTemplate {
2587        version: env!("CARGO_PKG_VERSION"),
2588        recent_scans_json,
2589        csp_nonce,
2590    };
2591    Html(
2592        template
2593            .render()
2594            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2595    )
2596}
2597
2598/// Build provenance embedded at compile time by `build.rs`. Falls back to
2599/// "unknown" on air-gapped builds with no git available.
2600const GIT_SHA: &str = env!("OXIDE_SLOC_GIT_SHA");
2601const BUILD_TIME: &str = env!("OXIDE_SLOC_BUILD_TIME");
2602
2603/// Process start instant, anchored the first time it is read. Called once during
2604/// `serve()` startup so uptime is measured from launch, not from the first probe.
2605pub(crate) fn process_start() -> std::time::Instant {
2606    static START: OnceLock<std::time::Instant> = OnceLock::new();
2607    *START.get_or_init(std::time::Instant::now)
2608}
2609
2610fn uptime_seconds() -> u64 {
2611    process_start().elapsed().as_secs()
2612}
2613
2614/// Liveness probe — the process is up and the event loop is servicing requests.
2615/// Deliberately trivial and dependency-free so container/systemd probes stay fast
2616/// and stable. Readiness (dependency health) is `/readyz`; rich status is `/api/health`.
2617async fn healthz() -> &'static str {
2618    "ok"
2619}
2620
2621/// Probe whether a directory is writable by round-tripping a tiny marker file.
2622/// An empty path is treated as writable (nothing to check).
2623fn dir_writable(dir: &std::path::Path) -> bool {
2624    if dir.as_os_str().is_empty() {
2625        return true;
2626    }
2627    let _ = std::fs::create_dir_all(dir);
2628    let probe = dir.join(".oxide-sloc-health-probe");
2629    match std::fs::write(&probe, b"") {
2630        Ok(()) => {
2631            let _ = std::fs::remove_file(&probe);
2632            true
2633        }
2634        Err(_) => false,
2635    }
2636}
2637
2638/// Dependency health checks backing `/api/health` and `/readyz`: can we persist
2639/// the registry and write scan artifacts? Returned in stable order.
2640fn health_checks(state: &AppState) -> Vec<(&'static str, bool)> {
2641    let registry_dir = state
2642        .registry_path
2643        .parent()
2644        .map_or_else(|| std::path::Path::new("."), |p| p);
2645    vec![
2646        ("registry_writable", dir_writable(registry_dir)),
2647        (
2648            "output_dir_writable",
2649            dir_writable(&resolve_output_root(None)),
2650        ),
2651    ]
2652}
2653
2654fn checks_to_json(checks: &[(&'static str, bool)]) -> serde_json::Value {
2655    let map: serde_json::Map<String, serde_json::Value> = checks
2656        .iter()
2657        .map(|(k, v)| ((*k).to_owned(), serde_json::Value::Bool(*v)))
2658        .collect();
2659    serde_json::Value::Object(map)
2660}
2661
2662/// Structured health/status endpoint (`/api/health`). Always answers 200 when the
2663/// process is responsive; the `status` field is `"ok"` when every dependency check
2664/// passes and `"degraded"` otherwise. Use `/readyz` for a pass/fail readiness gate.
2665async fn api_health_handler(State(state): State<AppState>) -> impl IntoResponse {
2666    let checks = health_checks(&state);
2667    let all_ok = checks.iter().all(|(_, ok)| *ok);
2668    axum::Json(serde_json::json!({
2669        "status": if all_ok { "ok" } else { "degraded" },
2670        "name": "oxide-sloc",
2671        "version": env!("CARGO_PKG_VERSION"),
2672        "git_sha": GIT_SHA,
2673        "build_time": BUILD_TIME,
2674        "uptime_seconds": uptime_seconds(),
2675        "checks": checks_to_json(&checks),
2676    }))
2677}
2678
2679/// Readiness probe (`/readyz`): 200 when the server can persist state and write
2680/// artifacts, 503 otherwise. Distinct from `/healthz` (liveness) so orchestrators
2681/// can hold traffic off a process that is up but unable to serve real work.
2682async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
2683    let checks = health_checks(&state);
2684    let ready = checks.iter().all(|(_, ok)| *ok);
2685    let code = if ready {
2686        axum::http::StatusCode::OK
2687    } else {
2688        axum::http::StatusCode::SERVICE_UNAVAILABLE
2689    };
2690    (
2691        code,
2692        axum::Json(serde_json::json!({
2693            "status": if ready { "ready" } else { "not_ready" },
2694            "checks": checks_to_json(&checks),
2695        })),
2696    )
2697}
2698
2699async fn api_version_handler() -> impl IntoResponse {
2700    axum::Json(serde_json::json!({
2701        "name": "oxide-sloc",
2702        "version": env!("CARGO_PKG_VERSION"),
2703        "git_sha": GIT_SHA,
2704        "build_time": BUILD_TIME,
2705    }))
2706}
2707
2708// ── Prometheus metrics ────────────────────────────────────────────────────────
2709
2710fn prom_runs_total() -> &'static prometheus::IntCounter {
2711    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2712    COUNTER.get_or_init(|| {
2713        prometheus::register_int_counter!(
2714            "oxide_sloc_runs_total",
2715            "Total number of completed analysis runs"
2716        )
2717        .expect("failed to register oxide_sloc_runs_total counter")
2718    })
2719}
2720
2721async fn metrics_handler() -> impl IntoResponse {
2722    use prometheus::Encoder as _;
2723    let mut buf = Vec::new();
2724    let encoder = prometheus::TextEncoder::new();
2725    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2726    (
2727        [(
2728            axum::http::header::CONTENT_TYPE,
2729            "text/plain; version=0.0.4; charset=utf-8",
2730        )],
2731        buf,
2732    )
2733}
2734
2735static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2736
2737async fn openapi_yaml_handler() -> impl IntoResponse {
2738    (
2739        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2740        OPENAPI_YAML,
2741    )
2742}
2743
2744static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2745static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2746
2747async fn llms_txt_handler() -> impl IntoResponse {
2748    (
2749        [
2750            (
2751                axum::http::header::CONTENT_TYPE,
2752                "text/plain; charset=utf-8",
2753            ),
2754            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2755        ],
2756        LLMS_TXT,
2757    )
2758}
2759
2760async fn llms_full_txt_handler() -> impl IntoResponse {
2761    (
2762        [
2763            (
2764                axum::http::header::CONTENT_TYPE,
2765                "text/plain; charset=utf-8",
2766            ),
2767            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2768        ],
2769        LLMS_FULL_TXT,
2770    )
2771}
2772
2773async fn api_docs_handler(
2774    State(state): State<AppState>,
2775    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2776) -> impl IntoResponse {
2777    let has_api_key = !state.api_keys.is_empty();
2778    Html(
2779        ApiDocsTemplate {
2780            has_api_key,
2781            csp_nonce,
2782            version: env!("CARGO_PKG_VERSION"),
2783        }
2784        .render()
2785        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2786    )
2787}
2788
2789async fn chart_js_handler() -> impl IntoResponse {
2790    (
2791        [
2792            (
2793                header::CONTENT_TYPE,
2794                "application/javascript; charset=utf-8",
2795            ),
2796            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2797        ],
2798        CHART_JS,
2799    )
2800}
2801
2802async fn report_chart_js_handler() -> impl IntoResponse {
2803    (
2804        [
2805            (
2806                header::CONTENT_TYPE,
2807                "application/javascript; charset=utf-8",
2808            ),
2809            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2810        ],
2811        REPORT_CHART_JS,
2812    )
2813}
2814
2815#[derive(Debug, Deserialize)]
2816struct AnalyzeForm {
2817    path: String,
2818    git_repo: Option<String>,
2819    git_ref: Option<String>,
2820    mixed_line_policy: Option<MixedLinePolicy>,
2821    python_docstrings_as_comments: Option<String>,
2822    generated_file_detection: Option<String>,
2823    minified_file_detection: Option<String>,
2824    vendor_directory_detection: Option<String>,
2825    include_lockfiles: Option<String>,
2826    binary_file_behavior: Option<BinaryFileBehavior>,
2827    output_dir: Option<String>,
2828    report_title: Option<String>,
2829    report_header_footer: Option<String>,
2830    include_globs: Option<String>,
2831    exclude_globs: Option<String>,
2832    submodule_breakdown: Option<String>,
2833    coverage_file: Option<String>,
2834    continuation_line_policy: Option<ContinuationLinePolicy>,
2835    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2836    count_compiler_directives: Option<String>,
2837    style_col_threshold: Option<String>,
2838    style_analysis_enabled: Option<String>,
2839    style_score_threshold: Option<String>,
2840    style_lang_scope: Option<String>,
2841    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2842    cocomo_mode: Option<String>,
2843    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2844    complexity_alert: Option<String>,
2845    /// Whether to exclude duplicate files from displayed SLOC totals.
2846    exclude_duplicates: Option<String>,
2847    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2848    activity_window: Option<String>,
2849}
2850
2851#[allow(clippy::struct_excessive_bools)]
2852#[derive(Debug, Serialize, Deserialize, Clone)]
2853struct ScanConfig {
2854    oxide_sloc_version: String,
2855    path: String,
2856    include_globs: String,
2857    exclude_globs: String,
2858    submodule_breakdown: bool,
2859    mixed_line_policy: String,
2860    python_docstrings_as_comments: bool,
2861    generated_file_detection: bool,
2862    minified_file_detection: bool,
2863    vendor_directory_detection: bool,
2864    include_lockfiles: bool,
2865    binary_file_behavior: String,
2866    output_dir: String,
2867    report_title: String,
2868    // IEEE 1045-1992 and advanced fields added in later release
2869    #[serde(default = "default_each_physical_line")]
2870    continuation_line_policy: String,
2871    #[serde(default = "default_count_as_comment")]
2872    blank_in_block_comment_policy: String,
2873    #[serde(default = "default_true_bool")]
2874    count_compiler_directives: bool,
2875    #[serde(default = "default_true_bool")]
2876    style_analysis_enabled: bool,
2877    #[serde(default = "default_style_col_threshold")]
2878    style_col_threshold: u16,
2879    #[serde(default)]
2880    style_score_threshold: u8,
2881    #[serde(default = "default_all_scope")]
2882    style_lang_scope: String,
2883    #[serde(default)]
2884    coverage_file: String,
2885    #[serde(default = "default_organic")]
2886    cocomo_mode: String,
2887    #[serde(default)]
2888    complexity_alert: u32,
2889    #[serde(default)]
2890    exclude_duplicates: bool,
2891    /// Git hotspots activity window in days (on by default; 0 = disabled).
2892    #[serde(default = "default_activity_window")]
2893    activity_window: u32,
2894}
2895
2896const fn default_activity_window() -> u32 {
2897    90
2898}
2899
2900fn default_each_physical_line() -> String {
2901    "each_physical_line".to_string()
2902}
2903fn default_count_as_comment() -> String {
2904    "count_as_comment".to_string()
2905}
2906const fn default_true_bool() -> bool {
2907    true
2908}
2909const fn default_style_col_threshold() -> u16 {
2910    80
2911}
2912fn default_all_scope() -> String {
2913    "all".to_string()
2914}
2915fn default_organic() -> String {
2916    "organic".to_string()
2917}
2918
2919#[derive(Debug, Deserialize, Default)]
2920struct IndexQuery {
2921    path: Option<String>,
2922    include_globs: Option<String>,
2923    exclude_globs: Option<String>,
2924    submodule_breakdown: Option<String>,
2925    mixed_line_policy: Option<String>,
2926    python_docstrings_as_comments: Option<String>,
2927    generated_file_detection: Option<String>,
2928    minified_file_detection: Option<String>,
2929    vendor_directory_detection: Option<String>,
2930    include_lockfiles: Option<String>,
2931    binary_file_behavior: Option<String>,
2932    output_dir: Option<String>,
2933    report_title: Option<String>,
2934    prefilled: Option<String>,
2935    git_repo: Option<String>,
2936    git_ref: Option<String>,
2937    // IEEE 1045-1992 and advanced fields
2938    continuation_line_policy: Option<String>,
2939    blank_in_block_comment_policy: Option<String>,
2940    count_compiler_directives: Option<String>,
2941    style_analysis_enabled: Option<String>,
2942    style_col_threshold: Option<String>,
2943    style_score_threshold: Option<String>,
2944    style_lang_scope: Option<String>,
2945    coverage_file: Option<String>,
2946    cocomo_mode: Option<String>,
2947    complexity_alert: Option<String>,
2948    exclude_duplicates: Option<String>,
2949    activity_window: Option<String>,
2950}
2951
2952#[derive(Debug, Deserialize)]
2953struct PreviewQuery {
2954    path: Option<String>,
2955    include_globs: Option<String>,
2956    exclude_globs: Option<String>,
2957}
2958
2959#[cfg(feature = "native-dialog")]
2960#[derive(Debug, Deserialize)]
2961struct PickDirectoryQuery {
2962    kind: Option<String>,
2963    current: Option<String>,
2964}
2965
2966#[cfg(not(feature = "native-dialog"))]
2967#[derive(Debug, Deserialize)]
2968struct PickDirectoryQuery {}
2969
2970#[derive(Debug, Deserialize, Default)]
2971struct ArtifactQuery {
2972    download: Option<String>,
2973}
2974
2975#[cfg(feature = "native-dialog")]
2976#[derive(Debug, Serialize)]
2977struct PickDirectoryResponse {
2978    selected_path: Option<String>,
2979    cancelled: bool,
2980}
2981
2982#[cfg(feature = "native-dialog")]
2983async fn pick_directory_handler(
2984    State(state): State<AppState>,
2985    Query(query): Query<PickDirectoryQuery>,
2986) -> Response {
2987    if state.server_mode {
2988        return StatusCode::NOT_FOUND.into_response();
2989    }
2990    // Return immediately without opening a dialog in headless / CI environments.
2991    if std::env::var("SLOC_HEADLESS").is_ok() {
2992        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2993            .into_response();
2994    }
2995
2996    let is_coverage = query.kind.as_deref() == Some("coverage");
2997    let title = match query.kind.as_deref() {
2998        Some("output") => "Select output directory",
2999        Some("reports") => "Select folder containing saved reports",
3000        Some("coverage") => "Select LCOV coverage file",
3001        _ => "Select project directory",
3002    }
3003    .to_owned();
3004    let current = query.current.clone();
3005
3006    let picked = tokio::task::spawn_blocking(move || {
3007        // Windows: attach to the foreground thread so the dialog inherits focus,
3008        // and kick off a watcher that flashes the dialog once it appears.
3009        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3010        let fg_tid = win_dialog_focus::attach_to_foreground();
3011        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3012        win_dialog_focus::flash_dialog_when_ready(title.clone());
3013
3014        let mut dialog = rfd::FileDialog::new().set_title(&title);
3015        if let Some(current) = current.as_deref() {
3016            let resolved = resolve_input_path(current);
3017            let seed = if resolved.is_dir() {
3018                Some(resolved)
3019            } else {
3020                resolved.parent().map(Path::to_path_buf)
3021            };
3022            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
3023                dialog = dialog.set_directory(seed_dir);
3024            }
3025        }
3026        let result = if is_coverage {
3027            dialog
3028                .add_filter(
3029                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
3030                    &["info", "lcov", "xml", "json"],
3031                )
3032                .pick_file()
3033        } else {
3034            dialog.pick_folder()
3035        };
3036
3037        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3038        win_dialog_focus::detach_from_foreground(fg_tid);
3039
3040        result
3041    })
3042    .await
3043    .unwrap_or(None);
3044
3045    Json(PickDirectoryResponse {
3046        selected_path: picked.as_ref().map(|p| display_path(p)),
3047        cancelled: picked.is_none(),
3048    })
3049    .into_response()
3050}
3051
3052#[cfg(not(feature = "native-dialog"))]
3053async fn pick_directory_handler(
3054    State(_state): State<AppState>,
3055    Query(_query): Query<PickDirectoryQuery>,
3056) -> Response {
3057    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3058}
3059
3060#[cfg(feature = "native-dialog")]
3061async fn pick_file_handler(State(state): State<AppState>) -> Response {
3062    if state.server_mode {
3063        return StatusCode::NOT_FOUND.into_response();
3064    }
3065    if std::env::var("SLOC_HEADLESS").is_ok() {
3066        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
3067            .into_response();
3068    }
3069    let picked = tokio::task::spawn_blocking(|| {
3070        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3071        let fg_tid = win_dialog_focus::attach_to_foreground();
3072        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3073        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
3074
3075        let result = rfd::FileDialog::new()
3076            .set_title("Select HTML report")
3077            .add_filter("HTML report", &["html"])
3078            .pick_file();
3079
3080        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3081        win_dialog_focus::detach_from_foreground(fg_tid);
3082
3083        result
3084    })
3085    .await
3086    .unwrap_or(None);
3087    Json(PickDirectoryResponse {
3088        selected_path: picked.as_ref().map(|p| display_path(p)),
3089        cancelled: picked.is_none(),
3090    })
3091    .into_response()
3092}
3093
3094#[cfg(not(feature = "native-dialog"))]
3095async fn pick_file_handler(State(_state): State<AppState>) -> Response {
3096    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3097}
3098
3099// ── Browser-upload handlers (server mode only) ────────────────────────────────
3100
3101/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
3102/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
3103fn is_upload_tmp_path(path: &Path) -> bool {
3104    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
3105    path.starts_with(&upload_root)
3106}
3107
3108/// Returns true when `path` is the built-in sample or test-fixture directory.
3109/// These paths ship with the server binary and are always safe to scan/preview.
3110fn is_sample_path(path: &Path) -> bool {
3111    let root = workspace_root();
3112    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
3113}
3114
3115/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
3116fn upload_base_dir() -> PathBuf {
3117    std::env::temp_dir().join("oxide-sloc-uploads")
3118}
3119
3120/// Returns the staging path for a given upload id inside the base dir.
3121fn upload_staging_path(id: &str) -> PathBuf {
3122    upload_base_dir().join(id)
3123}
3124
3125/// Validate basic field constraints on a directory-upload request.
3126/// Returns an error `Response` if the request should be rejected immediately.
3127#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3128fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
3129    const MAX_FILES: usize = 50_000;
3130    if body.files.is_empty() {
3131        return Err((
3132            StatusCode::BAD_REQUEST,
3133            Json(serde_json::json!({"error": "No files received"})),
3134        )
3135            .into_response());
3136    }
3137    if body.files.len() > MAX_FILES {
3138        return Err((
3139            StatusCode::PAYLOAD_TOO_LARGE,
3140            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
3141        )
3142            .into_response());
3143    }
3144    Ok(())
3145}
3146
3147/// Resolve or create the staging directory for a directory upload.
3148/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
3149fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
3150    match id {
3151        Some(id)
3152            if !id.is_empty()
3153                && id.len() <= 36
3154                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
3155        {
3156            (id.to_string(), upload_staging_path(id))
3157        }
3158        _ => {
3159            let new_id = uuid::Uuid::new_v4().to_string();
3160            let staging = upload_staging_path(&new_id);
3161            (new_id, staging)
3162        }
3163    }
3164}
3165
3166/// Decode, size-check, and write one uploaded file entry into `staging`.
3167/// Returns `Ok(())` whether the file was written or skipped (bad base64).
3168/// Returns `Err(Response)` for fatal errors; the caller is responsible for
3169/// cleaning up `staging` before propagating the error.
3170#[allow(clippy::result_large_err)]
3171async fn stage_decoded_entry(
3172    entry: &UploadedFile,
3173    staging: &Path,
3174    total_bytes: &mut usize,
3175    project_root: &mut Option<PathBuf>,
3176) -> Result<(), Response> {
3177    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
3178
3179    let Ok(data) = base64::Engine::decode(
3180        &base64::engine::general_purpose::STANDARD,
3181        entry.content.as_bytes(),
3182    ) else {
3183        return Ok(());
3184    };
3185
3186    *total_bytes += data.len();
3187    if *total_bytes > MAX_TOTAL_BYTES {
3188        return Err((
3189            StatusCode::PAYLOAD_TOO_LARGE,
3190            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
3191        )
3192            .into_response());
3193    }
3194
3195    let rel = std::path::Path::new(&entry.path);
3196    if project_root.is_none()
3197        && let Some(first) = rel.components().next()
3198    {
3199        *project_root = Some(staging.join(first.as_os_str()));
3200    }
3201
3202    let dest = staging.join(rel);
3203    if let Some(parent) = dest.parent()
3204        && tokio::fs::create_dir_all(parent).await.is_err()
3205    {
3206        return Err((
3207            StatusCode::INTERNAL_SERVER_ERROR,
3208            Json(serde_json::json!({"error": "Failed to create directory structure"})),
3209        )
3210            .into_response());
3211    }
3212
3213    if tokio::fs::write(&dest, &data).await.is_err() {
3214        return Err((
3215            StatusCode::INTERNAL_SERVER_ERROR,
3216            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3217        )
3218            .into_response());
3219    }
3220
3221    Ok(())
3222}
3223
3224/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
3225/// and path-traversal guard. Returns `(file_count, project_root)` on success or
3226/// an error `Response` on failure (staging dir is cleaned up before returning).
3227async fn write_upload_files(
3228    files: &[UploadedFile],
3229    staging: &Path,
3230    upload_id: &str,
3231) -> Result<(usize, Option<PathBuf>), Response> {
3232    let mut total_bytes: usize = 0;
3233    let mut project_root: Option<PathBuf> = None;
3234
3235    for entry in files {
3236        let rel = std::path::Path::new(&entry.path);
3237        if rel
3238            .components()
3239            .any(|c| matches!(c, std::path::Component::ParentDir))
3240        {
3241            // Reject the entire upload on the first path traversal attempt.
3242            let _ = tokio::fs::remove_dir_all(staging).await;
3243            tracing::warn!(
3244                event = "upload_path_traversal",
3245                upload_id = %upload_id,
3246                path = %entry.path,
3247                "Upload rejected: path traversal component detected"
3248            );
3249            return Err((
3250                StatusCode::BAD_REQUEST,
3251                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3252            )
3253                .into_response());
3254        }
3255
3256        if let Err(resp) =
3257            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3258        {
3259            let _ = tokio::fs::remove_dir_all(staging).await;
3260            return Err(resp);
3261        }
3262    }
3263
3264    Ok((files.len(), project_root))
3265}
3266
3267/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
3268/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
3269fn parse_tarball_size_caps() -> (u64, u64) {
3270    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3271        .ok()
3272        .and_then(|v| v.parse().ok())
3273        .unwrap_or(2048_u64)
3274        * 1024
3275        * 1024;
3276    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3277        .ok()
3278        .and_then(|v| v.parse().ok())
3279        .unwrap_or(10_240_u64)
3280        * 1024
3281        * 1024;
3282    (compressed, decompressed)
3283}
3284
3285/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
3286/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
3287/// are rejected before the streaming handler is invoked.
3288fn tarball_http_body_limit_bytes() -> usize {
3289    std::env::var("SLOC_MAX_TARBALL_MB")
3290        .ok()
3291        .and_then(|v| v.parse::<usize>().ok())
3292        .unwrap_or(2048)
3293        .saturating_mul(1024 * 1024)
3294}
3295
3296/// Stream `body` into `dest_path`, enforcing `max_bytes`.
3297/// Returns the number of compressed bytes written, or an error `Response`.
3298/// Cleans up `dest_path` on error.
3299#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3300async fn stream_body_to_file(
3301    body: axum::body::Body,
3302    dest_path: &Path,
3303    max_bytes: u64,
3304) -> Result<u64, Response> {
3305    use http_body_util::BodyExt as _;
3306    use tokio::io::AsyncWriteExt as _;
3307
3308    let mut file = match tokio::fs::File::create(dest_path).await {
3309        Ok(f) => f,
3310        Err(e) => {
3311            tracing::error!(
3312                event = "upload_io_error",
3313                "failed to create tarball temp file: {e}"
3314            );
3315            return Err((
3316                StatusCode::INTERNAL_SERVER_ERROR,
3317                Json(serde_json::json!({"error": "Upload initialization failed"})),
3318            )
3319                .into_response());
3320        }
3321    };
3322
3323    let mut body = body;
3324    let mut written: u64 = 0;
3325    loop {
3326        match body.frame().await {
3327            None => break,
3328            Some(Err(e)) => {
3329                let _ = tokio::fs::remove_file(dest_path).await;
3330                return Err((
3331                    StatusCode::BAD_REQUEST,
3332                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3333                )
3334                    .into_response());
3335            }
3336            Some(Ok(frame)) => {
3337                if let Ok(data) = frame.into_data() {
3338                    written += data.len() as u64;
3339                    if written > max_bytes {
3340                        let _ = tokio::fs::remove_file(dest_path).await;
3341                        return Err((
3342                            StatusCode::PAYLOAD_TOO_LARGE,
3343                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3344                        )
3345                            .into_response());
3346                    }
3347                    if let Err(e) = file.write_all(&data).await {
3348                        let _ = tokio::fs::remove_file(dest_path).await;
3349                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3350                        return Err((
3351                            StatusCode::INTERNAL_SERVER_ERROR,
3352                            Json(serde_json::json!({"error": "Upload write failed"})),
3353                        )
3354                            .into_response());
3355                    }
3356                }
3357            }
3358        }
3359    }
3360    drop(file);
3361    Ok(written)
3362}
3363
3364/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
3365/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
3366/// on failure (staging dir is cleaned up before returning).
3367#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3368async fn extract_tarball_to_staging(
3369    tarball_path: &Path,
3370    staging: &Path,
3371    max_decompressed_bytes: u64,
3372) -> Result<(), Response> {
3373    let staging_clone = staging.to_path_buf();
3374    let tarball_clone = tarball_path.to_path_buf();
3375    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3376        let file = std::fs::File::open(&tarball_clone)?;
3377        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3378        let limited = SizeLimitReader {
3379            inner: gz,
3380            remaining: max_decompressed_bytes,
3381        };
3382        let mut archive = tar::Archive::new(limited);
3383        archive.set_overwrite(true);
3384        archive.set_preserve_permissions(false);
3385        std::fs::create_dir_all(&staging_clone)?;
3386        archive.unpack(&staging_clone)?;
3387        Ok(())
3388    })
3389    .await;
3390    let _ = tokio::fs::remove_file(tarball_path).await;
3391
3392    match extract_result {
3393        Ok(Ok(())) => Ok(()),
3394        Ok(Err(e)) => {
3395            let _ = tokio::fs::remove_dir_all(staging).await;
3396            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3397            tracing::warn!(
3398                event = "upload_extract_error",
3399                "tarball extraction failed: {e:#}"
3400            );
3401            let (status, msg) = if is_size_limit {
3402                (
3403                    StatusCode::PAYLOAD_TOO_LARGE,
3404                    "Archive exceeds the decompressed size limit",
3405                )
3406            } else {
3407                (StatusCode::BAD_REQUEST, "Failed to extract archive")
3408            };
3409            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3410        }
3411        Err(e) => {
3412            let _ = tokio::fs::remove_dir_all(staging).await;
3413            tracing::error!(
3414                event = "upload_extract_panic",
3415                "tarball extraction task panicked: {e}"
3416            );
3417            Err((
3418                StatusCode::INTERNAL_SERVER_ERROR,
3419                Json(serde_json::json!({"error": "Archive extraction failed"})),
3420            )
3421                .into_response())
3422        }
3423    }
3424}
3425
3426/// If `staging` contains exactly one top-level directory, return its path
3427/// (the common case when the archive was created with `webkitRelativePath`).
3428/// Otherwise return `None`.
3429async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3430    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3431    let first = entries.next_entry().await.ok()??;
3432    if !first.path().is_dir() {
3433        return None;
3434    }
3435    if entries.next_entry().await.unwrap_or(None).is_some() {
3436        return None;
3437    }
3438    Some(first.path())
3439}
3440
3441/// Request body for `POST /api/upload-directory`.
3442///
3443/// Each entry carries a relative path (identical to the browser's
3444/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
3445/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
3446/// avoids pulling in a `multipart` library that is not in the vendor archive.
3447#[derive(Deserialize)]
3448struct UploadDirRequest {
3449    files: Vec<UploadedFile>,
3450    /// If provided, append this batch to an existing upload session instead of
3451    /// creating a new staging directory. Must be a plain UUID (no path separators).
3452    upload_id: Option<String>,
3453}
3454
3455#[derive(Deserialize)]
3456struct UploadedFile {
3457    /// `webkitRelativePath` value from the browser File object.
3458    path: String,
3459    /// Raw file bytes encoded as standard base64.
3460    content: String,
3461}
3462
3463/// POST /api/upload-directory
3464///
3465/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
3466/// Saves all files to a temp staging directory preserving their relative paths,
3467/// then returns the server-side root directory path so the caller can populate
3468/// the scan-path field and run a normal analysis.
3469///
3470/// Only available in server mode; returns 404 in local mode (use the native
3471/// rfd dialog instead).
3472async fn upload_directory_handler(
3473    State(state): State<AppState>,
3474    Json(body): Json<UploadDirRequest>,
3475) -> Response {
3476    if !state.server_mode {
3477        return StatusCode::NOT_FOUND.into_response();
3478    }
3479    if let Err(resp) = validate_upload_dir_request(&body) {
3480        return resp;
3481    }
3482    // Reuse an existing staging dir when the client sends a continuation batch,
3483    // otherwise create a fresh one. Validate the id to prevent path traversal.
3484    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3485    match write_upload_files(&body.files, &staging, &upload_id).await {
3486        Ok((file_count, project_root)) => {
3487            let scan_root = project_root.unwrap_or_else(|| staging.clone());
3488            Json(serde_json::json!({
3489                "tmp_path": scan_root.to_string_lossy(),
3490                "file_count": file_count,
3491                "upload_id": upload_id.clone()
3492            }))
3493            .into_response()
3494        }
3495        Err(resp) => resp,
3496    }
3497}
3498
3499/// Request body for `POST /api/upload-file`.
3500#[derive(Deserialize)]
3501struct UploadFileRequest {
3502    /// Original filename (used only to preserve the extension).
3503    filename: String,
3504    /// File bytes encoded as standard base64.
3505    content: String,
3506}
3507
3508/// POST /api/upload-file
3509///
3510/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
3511/// Accepts `{ "filename": "…", "content": "<base64>" }`.
3512/// Only available in server mode.
3513async fn upload_file_handler(
3514    State(state): State<AppState>,
3515    Json(body): Json<UploadFileRequest>,
3516) -> Response {
3517    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
3518
3519    if !state.server_mode {
3520        return StatusCode::NOT_FOUND.into_response();
3521    }
3522
3523    let Ok(data) = base64::Engine::decode(
3524        &base64::engine::general_purpose::STANDARD,
3525        body.content.as_bytes(),
3526    ) else {
3527        return (
3528            StatusCode::BAD_REQUEST,
3529            Json(serde_json::json!({"error": "Invalid base64 content"})),
3530        )
3531            .into_response();
3532    };
3533
3534    if data.len() > MAX_FILE_BYTES {
3535        return (
3536            StatusCode::PAYLOAD_TOO_LARGE,
3537            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3538        )
3539            .into_response();
3540    }
3541
3542    // Sanitise: strip any directory component from the filename.
3543    let filename = std::path::Path::new(&body.filename)
3544        .file_name()
3545        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3546
3547    let upload_id = uuid::Uuid::new_v4();
3548    let staging = std::env::temp_dir()
3549        .join("oxide-sloc-uploads")
3550        .join(upload_id.to_string());
3551
3552    if tokio::fs::create_dir_all(&staging).await.is_err() {
3553        return (
3554            StatusCode::INTERNAL_SERVER_ERROR,
3555            Json(serde_json::json!({"error": "Failed to create staging directory"})),
3556        )
3557            .into_response();
3558    }
3559
3560    let dest = staging.join(&filename);
3561    if tokio::fs::write(&dest, &data).await.is_err() {
3562        let _ = tokio::fs::remove_dir_all(&staging).await;
3563        return (
3564            StatusCode::INTERNAL_SERVER_ERROR,
3565            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3566        )
3567            .into_response();
3568    }
3569
3570    Json(serde_json::json!({
3571        "tmp_path": dest.to_string_lossy(),
3572        "upload_id": upload_id.to_string()
3573    }))
3574    .into_response()
3575}
3576
3577/// POST /api/upload-tarball
3578///
3579/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
3580/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
3581/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
3582/// project root. The two size fields power the "Original / Compressed project size" display in the
3583/// web UI.
3584///
3585/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
3586/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
3587/// cap during decompression. The browser-side JS creates the archive one file at a time using
3588/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
3589/// project size.
3590/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
3591/// decompressed. Wraps any `std::io::Read` source.
3592struct SizeLimitReader<R> {
3593    inner: R,
3594    remaining: u64,
3595}
3596impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3597    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3598        if self.remaining == 0 {
3599            return Err(std::io::Error::other("decompressed size limit exceeded"));
3600        }
3601        let n = self.inner.read(buf)?;
3602        self.remaining = self.remaining.saturating_sub(n as u64);
3603        Ok(n)
3604    }
3605}
3606
3607async fn upload_tarball_handler(
3608    State(state): State<AppState>,
3609    request: axum::extract::Request,
3610) -> Response {
3611    if !state.server_mode {
3612        return StatusCode::NOT_FOUND.into_response();
3613    }
3614
3615    let upload_id = uuid::Uuid::new_v4().to_string();
3616    let upload_base = upload_base_dir();
3617    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3618    let staging = upload_staging_path(&upload_id);
3619    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3620
3621    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3622        tracing::error!(
3623            event = "upload_io_error",
3624            "failed to create upload base dir: {e}"
3625        );
3626        return (
3627            StatusCode::INTERNAL_SERVER_ERROR,
3628            Json(serde_json::json!({"error": "Upload initialization failed"})),
3629        )
3630            .into_response();
3631    }
3632
3633    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
3634    let compressed_bytes =
3635        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3636            Ok(n) => n,
3637            Err(resp) => return resp,
3638        };
3639
3640    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
3641    if let Err(resp) =
3642        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3643    {
3644        return resp;
3645    }
3646
3647    // ── 3. Find the project root inside the staging dir ───────────────────────
3648    // If the tar contained a single top-level directory (the common case when the
3649    // browser uses `webkitRelativePath`), return that as the scan root so the path
3650    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3651    let scan_root = find_single_top_dir(&staging)
3652        .await
3653        .unwrap_or_else(|| staging.clone());
3654
3655    // Compute original (uncompressed) size of the extracted tree.
3656    let original_bytes = tokio::task::spawn_blocking({
3657        let p = scan_root.clone();
3658        move || dir_size_bytes(&p)
3659    })
3660    .await
3661    .unwrap_or(0);
3662
3663    Json(serde_json::json!({
3664        "tmp_path": scan_root.to_string_lossy(),
3665        "upload_id": upload_id,
3666        "compressed_bytes": compressed_bytes,
3667        "original_bytes": original_bytes,
3668    }))
3669    .into_response()
3670}
3671
3672#[derive(Deserialize)]
3673struct LocateReportForm {
3674    file_path: String,
3675    #[serde(default)]
3676    redirect_url: Option<String>,
3677    #[serde(default)]
3678    expected_run_id: Option<String>,
3679}
3680
3681/// Render a view-reports error page and return it as a `Response`.
3682fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3683    let html = ErrorTemplate {
3684        message: message.into(),
3685        last_report_url: Some("/view-reports".to_string()),
3686        last_report_label: Some("View Reports".to_string()),
3687        run_id: None,
3688        error_code: None,
3689        csp_nonce: csp_nonce.to_owned(),
3690        version: env!("CARGO_PKG_VERSION"),
3691    }
3692    .render()
3693    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3694    Html(html).into_response()
3695}
3696
3697/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3698fn registry_entry_from_run(
3699    run: &AnalysisRun,
3700    json_path: PathBuf,
3701    html_path: PathBuf,
3702) -> RegistryEntry {
3703    let project_label = run.input_roots.first().map_or_else(
3704        || "Unknown Project".to_string(),
3705        |r| sanitize_project_label(r),
3706    );
3707    RegistryEntry {
3708        run_id: run.tool.run_id.clone(),
3709        timestamp_utc: run.tool.timestamp_utc,
3710        project_label,
3711        input_roots: run.input_roots.clone(),
3712        json_path: Some(json_path),
3713        html_path: Some(html_path),
3714        pdf_path: None,
3715        summary: ScanSummarySnapshot::from(&run.summary_totals),
3716        csv_path: None,
3717        xlsx_path: None,
3718        git_branch: None,
3719        git_commit: None,
3720        git_commit_long: None,
3721        git_author: None,
3722        git_tags: None,
3723        git_nearest_tag: None,
3724        git_commit_date: None,
3725    }
3726}
3727
3728/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3729/// immediately without requiring a server restart.
3730pub(crate) async fn register_artifacts_in_registry(
3731    state: &AppState,
3732    label: &str,
3733    run: &AnalysisRun,
3734    artifacts: &RunArtifacts,
3735) {
3736    let Some(json_path) = artifacts.json_path.clone() else {
3737        return;
3738    };
3739    let Some(html_path) = artifacts.html_path.clone() else {
3740        return;
3741    };
3742    let mut entry = registry_entry_from_run(run, json_path, html_path);
3743    entry.project_label = label.to_owned();
3744    let mut reg = state.registry.lock().await;
3745    reg.add_entry(entry);
3746    let _ = reg.save(&state.registry_path);
3747}
3748
3749fn is_html_report_file(p: &Path) -> bool {
3750    p.is_file()
3751        && p.extension()
3752            .and_then(|x| x.to_str())
3753            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3754        && p.file_name()
3755            .and_then(|n| n.to_str())
3756            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3757}
3758
3759fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3760    fs::read_dir(dir)
3761        .ok()?
3762        .flatten()
3763        .map(|e| e.path())
3764        .find(|p| is_html_report_file(p))
3765}
3766
3767fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3768    if let Some(f) = find_html_report_in_dir(dir) {
3769        return Some(f);
3770    }
3771    if let Ok(rd) = fs::read_dir(dir) {
3772        for entry in rd.flatten() {
3773            let sub = entry.path();
3774            if sub.is_dir()
3775                && let Some(f) = find_html_report_in_dir(&sub)
3776            {
3777                return Some(f);
3778            }
3779        }
3780    }
3781    None
3782}
3783
3784/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3785/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3786///
3787/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3788#[allow(clippy::result_large_err)]
3789fn validate_locate_request(
3790    state: &AppState,
3791    file_path: &str,
3792    csp_nonce: &str,
3793) -> Result<(PathBuf, PathBuf), Response> {
3794    let raw = PathBuf::from(file_path);
3795
3796    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3797    let html_path = if raw.is_dir() {
3798        let found = find_html_report_in_tree(&raw);
3799        match found {
3800            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3801            None => {
3802                return Err(locate_report_error(
3803                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3804                     the folder that contains your scan output (result_*.html or report_*.html).",
3805                    csp_nonce,
3806                ));
3807            }
3808        }
3809    } else {
3810        let file_ext = raw
3811            .extension()
3812            .and_then(|e| e.to_str())
3813            .unwrap_or("")
3814            .to_ascii_lowercase();
3815        if file_ext != "html" {
3816            return Err(locate_report_error(
3817                "Please select the scan output folder, or an .html report file directly.",
3818                csp_nonce,
3819            ));
3820        }
3821        match fs::canonicalize(&raw) {
3822            Ok(p) => strip_unc_prefix(p),
3823            Err(_) => {
3824                return Err(locate_report_error(
3825                    "Report file not found or path is invalid.",
3826                    csp_nonce,
3827                ));
3828            }
3829        }
3830    };
3831
3832    if state.server_mode {
3833        let output_root = resolve_output_root(None);
3834        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3835        if !html_path.starts_with(&canonical_root) {
3836            return Err(locate_report_error(
3837                "Report file must be within the configured output directory.",
3838                csp_nonce,
3839            ));
3840        }
3841    }
3842    let parent = match html_path.parent() {
3843        Some(p) => p.to_path_buf(),
3844        None => {
3845            return Err(locate_report_error(
3846                "Report file has no parent directory.",
3847                csp_nonce,
3848            ));
3849        }
3850    };
3851    Ok((html_path, parent))
3852}
3853
3854/// JSON-or-HTML error for `locate_report_handler` error paths.
3855fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3856    if want_json {
3857        (
3858            StatusCode::UNPROCESSABLE_ENTITY,
3859            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3860        )
3861            .into_response()
3862    } else {
3863        locate_report_error(msg, csp_nonce)
3864    }
3865}
3866
3867/// JSON-or-redirect success for locate/relocate handler success paths.
3868fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3869    if want_json {
3870        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3871    } else {
3872        axum::response::Redirect::to(redirect).into_response()
3873    }
3874}
3875
3876/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3877/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3878fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3879    for jpath in candidates {
3880        if let Ok(run) = read_json(jpath)
3881            && (expected.is_empty() || run.tool.run_id == expected)
3882        {
3883            return Some((jpath.clone(), run.tool.run_id));
3884        }
3885    }
3886    None
3887}
3888
3889fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3890    html_path
3891        .parent()
3892        .and_then(|p| p.parent())
3893        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3894}
3895
3896fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3897    let mut hits = collect_result_json_candidates(scan_root);
3898    if hits.is_empty() {
3899        hits = collect_result_json_candidates(parent);
3900    }
3901    hits.sort();
3902    hits
3903}
3904
3905#[allow(clippy::too_many_lines)]
3906async fn locate_report_handler(
3907    State(state): State<AppState>,
3908    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3909    headers: axum::http::HeaderMap,
3910    Form(form): Form<LocateReportForm>,
3911) -> impl IntoResponse {
3912    let want_json = headers
3913        .get(axum::http::header::ACCEPT)
3914        .and_then(|v| v.to_str().ok())
3915        .is_some_and(|v| v.contains("application/json"));
3916
3917    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3918        Ok(v) => v,
3919        Err(resp) => {
3920            if want_json {
3921                return locate_handler_err(
3922                    true,
3923                    "No HTML report file found in the selected folder. \
3924                     Make sure you selected the folder that contains your \
3925                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3926                        .to_string(),
3927                    &csp_nonce,
3928                );
3929            }
3930            return resp;
3931        }
3932    };
3933
3934    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3935    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3936    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3937    let scan_root: &Path = &scan_root_owned;
3938    let json_candidates = gather_json_candidates(scan_root, &parent);
3939
3940    // If the expected_run_id was provided, find a JSON that matches it exactly.
3941    let expected_run_id = form
3942        .expected_run_id
3943        .as_deref()
3944        .unwrap_or("")
3945        .trim()
3946        .to_string();
3947
3948    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3949
3950    // If we have candidates but none matched the expected run_id, surface a clear error.
3951    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3952        let actual = json_candidates
3953            .iter()
3954            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3955            .unwrap_or_else(|| "unknown".to_string());
3956        return locate_handler_err(
3957            want_json,
3958            format!(
3959                "This folder contains a different scan.\n\n\
3960                 Expected run ID : {expected_run_id}\n\
3961                 Found run ID    : {actual}\n\n\
3962                 Please select the folder that contains the correct scan output."
3963            ),
3964            &csp_nonce,
3965        );
3966    }
3967
3968    let safe_redirect = form
3969        .redirect_url
3970        .as_deref()
3971        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3972        .unwrap_or("/view-reports?linked=1")
3973        .to_string();
3974
3975    let mut reg = state.registry.lock().await;
3976
3977    if let Some((json_path, run_id)) = matched_json {
3978        // Match by run_id in the registry (works even after files are moved).
3979        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3980            entry.html_path = Some(html_path);
3981            entry.json_path = Some(json_path);
3982            let _ = reg.save(&state.registry_path);
3983            drop(reg);
3984            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3985            state.artifacts.lock().await.remove(&run_id);
3986            return redirect_or_json_ok(want_json, &safe_redirect);
3987        }
3988        // No existing entry — build one from the JSON.
3989        match read_json(&json_path) {
3990            Ok(run) => {
3991                let entry = registry_entry_from_run(&run, json_path, html_path);
3992                reg.add_entry(entry);
3993                let _ = reg.save(&state.registry_path);
3994                drop(reg);
3995                state.artifacts.lock().await.remove(&run_id);
3996                return redirect_or_json_ok(want_json, &safe_redirect);
3997            }
3998            Err(e) => {
3999                drop(reg);
4000                return locate_handler_err(
4001                    want_json,
4002                    format!(
4003                        "Found the scan folder but could not parse the result JSON.\n\n\
4004                         The file may have been saved by an older version of OxideSLOC. \
4005                         Re-running the analysis will create a fresh, compatible record.\n\n\
4006                         Error: {e}"
4007                    ),
4008                    &csp_nonce,
4009                );
4010            }
4011        }
4012    }
4013
4014    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
4015    if let Some(entry) = reg
4016        .entries
4017        .iter_mut()
4018        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
4019    {
4020        entry.html_path = Some(html_path.clone());
4021        let _ = reg.save(&state.registry_path);
4022        drop(reg);
4023        state.artifacts.lock().await.remove(&expected_run_id);
4024        return redirect_or_json_ok(want_json, &safe_redirect);
4025    }
4026
4027    drop(reg);
4028    let hint = if state.server_mode {
4029        String::new()
4030    } else {
4031        format!(
4032            "\n\nSearched folder : {}\nHTML found      : {}",
4033            scan_root.display(),
4034            html_path.display()
4035        )
4036    };
4037    locate_handler_err(
4038        want_json,
4039        format!(
4040            "Could not link this report.\n\n\
4041             No result_*.json was found in the selected folder. \
4042             Make sure you selected the top-level scan output folder \
4043             (the one that contains html/, json/, pdf/ subfolders).{hint}"
4044        ),
4045        &csp_nonce,
4046    )
4047}
4048
4049/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
4050fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
4051    fs::read_dir(dir)
4052        .ok()?
4053        .flatten()
4054        .map(|e| e.path())
4055        .find(|p| {
4056            p.is_file()
4057                && p.file_stem()
4058                    .and_then(|n| n.to_str())
4059                    .is_some_and(|n| n.starts_with("result"))
4060                && p.extension()
4061                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
4062        })
4063}
4064
4065#[derive(Deserialize)]
4066struct LocateReportsDirForm {
4067    folder_path: String,
4068}
4069
4070#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
4071async fn locate_reports_dir_handler(
4072    State(state): State<AppState>,
4073    Form(form): Form<LocateReportsDirForm>,
4074) -> impl IntoResponse {
4075    if state.server_mode {
4076        return StatusCode::NOT_FOUND.into_response();
4077    }
4078    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
4079        Ok(p) => strip_unc_prefix(p),
4080        Err(_) => {
4081            return axum::response::Redirect::to(
4082                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
4083            )
4084            .into_response();
4085        }
4086    };
4087    if !folder.is_dir() {
4088        return axum::response::Redirect::to(
4089            "/view-reports?error=Selected+path+is+not+a+directory.",
4090        )
4091        .into_response();
4092    }
4093
4094    let candidates = collect_result_json_candidates(&folder);
4095
4096    if candidates.is_empty() {
4097        return axum::response::Redirect::to(
4098            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
4099        )
4100        .into_response();
4101    }
4102
4103    let mut linked_count: usize = 0;
4104    let mut reg = state.registry.lock().await;
4105    for json_path in candidates {
4106        let Some(parent) = json_path.parent().map(PathBuf::from) else {
4107            continue;
4108        };
4109        if is_dir_already_registered(&reg, &parent) {
4110            continue;
4111        }
4112        let Some(entry) = build_registry_entry_from_json(json_path) else {
4113            continue;
4114        };
4115        reg.add_entry(entry);
4116        linked_count += 1;
4117    }
4118    let _ = reg.save(&state.registry_path);
4119    drop(reg);
4120
4121    if linked_count == 0 {
4122        return axum::response::Redirect::to(
4123            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
4124        )
4125        .into_response();
4126    }
4127    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
4128}
4129
4130#[derive(Deserialize)]
4131struct RelocateScanForm {
4132    run_id: String,
4133    folder_path: String,
4134    redirect_url: String,
4135}
4136
4137/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
4138/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
4139fn relocate_folder_err(
4140    want_json: bool,
4141    status: StatusCode,
4142    msg: &str,
4143    run_id: &str,
4144    folder_hint: &str,
4145    redirect_url: &str,
4146    csp_nonce: &str,
4147) -> Response {
4148    if want_json {
4149        (
4150            status,
4151            axum::Json(serde_json::json!({"ok": false, "message": msg})),
4152        )
4153            .into_response()
4154    } else {
4155        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
4156    }
4157}
4158
4159#[allow(clippy::too_many_lines)]
4160async fn relocate_scan_handler(
4161    State(state): State<AppState>,
4162    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4163    headers: axum::http::HeaderMap,
4164    Form(form): Form<RelocateScanForm>,
4165) -> impl IntoResponse {
4166    let want_json = headers
4167        .get(axum::http::header::ACCEPT)
4168        .and_then(|v| v.to_str().ok())
4169        .is_some_and(|v| v.contains("application/json"));
4170    if state.server_mode {
4171        return StatusCode::NOT_FOUND.into_response();
4172    }
4173
4174    let run_id = form.run_id.trim().to_string();
4175    let redirect_url = form.redirect_url.trim().to_string();
4176
4177    let run_exists = {
4178        let reg = state.registry.lock().await;
4179        reg.find_by_run_id(&run_id).is_some()
4180    };
4181    if !run_exists {
4182        if want_json {
4183            return (
4184                StatusCode::NOT_FOUND,
4185                axum::Json(serde_json::json!({
4186                    "ok": false,
4187                    "message": format!("Run ID '{run_id}' not found in registry.")
4188                })),
4189            )
4190                .into_response();
4191        }
4192        let html = ErrorTemplate {
4193            message: format!("Run ID '{run_id}' not found in registry."),
4194            last_report_url: Some("/compare-scans".to_string()),
4195            last_report_label: Some("Compare Scans".to_string()),
4196            run_id: Some(run_id.clone()),
4197            error_code: Some(404),
4198            csp_nonce: csp_nonce.clone(),
4199            version: env!("CARGO_PKG_VERSION"),
4200        }
4201        .render()
4202        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4203        return Html(html).into_response();
4204    }
4205
4206    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
4207        Ok(p) => strip_unc_prefix(p),
4208        Err(_) => {
4209            return relocate_folder_err(
4210                want_json,
4211                StatusCode::UNPROCESSABLE_ENTITY,
4212                "Folder not found or path is invalid.",
4213                &run_id,
4214                form.folder_path.trim(),
4215                &redirect_url,
4216                &csp_nonce,
4217            );
4218        }
4219    };
4220    if !folder.is_dir() {
4221        return relocate_folder_err(
4222            want_json,
4223            StatusCode::UNPROCESSABLE_ENTITY,
4224            "Selected path is not a directory.",
4225            &run_id,
4226            &folder.display().to_string(),
4227            &redirect_url,
4228            &csp_nonce,
4229        );
4230    }
4231
4232    let json_candidates = find_result_files_by_ext(&folder, "json");
4233    if json_candidates.is_empty() {
4234        let msg = format!(
4235            "No result JSON files found in the selected folder.\nSearched: {}",
4236            folder.display()
4237        );
4238        return relocate_folder_err(
4239            want_json,
4240            StatusCode::UNPROCESSABLE_ENTITY,
4241            &msg,
4242            &run_id,
4243            &folder.display().to_string(),
4244            &redirect_url,
4245            &csp_nonce,
4246        );
4247    }
4248
4249    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4250        let msg = format!(
4251            "No matching scan found in the selected folder.\n\
4252             The JSON files present do not contain run ID: {run_id}\n\
4253             Searched: {}",
4254            folder.display()
4255        );
4256        return relocate_folder_err(
4257            want_json,
4258            StatusCode::UNPROCESSABLE_ENTITY,
4259            &msg,
4260            &run_id,
4261            &folder.display().to_string(),
4262            &redirect_url,
4263            &csp_nonce,
4264        );
4265    };
4266
4267    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4268    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4269    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4270
4271    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4272        redirect_url
4273    } else {
4274        "/compare-scans".to_string()
4275    };
4276    redirect_or_json_ok(want_json, &safe_redirect)
4277}
4278
4279fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4280    let mut out = Vec::new();
4281    collect_scan_files_by_ext(folder, ext, &mut out);
4282    if let Ok(rd) = fs::read_dir(folder) {
4283        for entry in rd.flatten() {
4284            let sub = entry.path();
4285            if sub.is_dir() {
4286                collect_scan_files_by_ext(&sub, ext, &mut out);
4287            }
4288        }
4289    }
4290    out
4291}
4292
4293fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4294    let Ok(rd) = fs::read_dir(dir) else { return };
4295    for entry in rd.flatten() {
4296        let p = entry.path();
4297        if p.is_file()
4298            && p.file_stem()
4299                .and_then(|n| n.to_str())
4300                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4301            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4302        {
4303            out.push(p);
4304        }
4305    }
4306}
4307
4308fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4309    candidates
4310        .iter()
4311        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4312        .cloned()
4313}
4314
4315/// Return the best folder hint for the relocate page.
4316/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
4317/// point at the parent — the actual top-level output directory — so the user
4318/// selects the root folder rather than the subfolder.
4319fn output_folder_hint(json_path: &std::path::Path) -> String {
4320    let Some(direct_parent) = json_path.parent() else {
4321        return String::new();
4322    };
4323    let parent_name = direct_parent
4324        .file_name()
4325        .and_then(|n| n.to_str())
4326        .unwrap_or("");
4327    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4328        direct_parent.parent().map_or_else(
4329            || direct_parent.display().to_string(),
4330            |p| p.display().to_string(),
4331        )
4332    } else {
4333        direct_parent.display().to_string()
4334    }
4335}
4336
4337async fn update_run_file_paths(
4338    state: &AppState,
4339    run_id: &str,
4340    json_path: PathBuf,
4341    html_path: Option<PathBuf>,
4342    pdf_path: Option<PathBuf>,
4343) {
4344    {
4345        let mut reg = state.registry.lock().await;
4346        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4347            entry.json_path = Some(json_path.clone());
4348            if let Some(ref hp) = html_path {
4349                entry.html_path = Some(hp.clone());
4350            }
4351            if let Some(ref pp) = pdf_path {
4352                entry.pdf_path = Some(pp.clone());
4353            }
4354        }
4355        let _ = reg.save(&state.registry_path);
4356    }
4357    // Also patch the in-memory artifacts map so the result page picks up the
4358    // new paths without requiring a server restart.
4359    {
4360        let mut map = state.artifacts.lock().await;
4361        if let Some(arts) = map.get_mut(run_id) {
4362            arts.json_path = Some(json_path);
4363            if let Some(hp) = html_path {
4364                arts.html_path = Some(hp);
4365            }
4366            if let Some(pp) = pdf_path {
4367                arts.pdf_path = Some(pp);
4368            }
4369        }
4370    }
4371}
4372
4373fn missing_scan_relocate_response(
4374    message: &str,
4375    run_id: &str,
4376    folder_hint: &str,
4377    redirect_url: &str,
4378    server_mode: bool,
4379    csp_nonce: &str,
4380) -> axum::response::Response {
4381    let html = RelocateScanTemplate {
4382        message: message.to_string(),
4383        run_id: run_id.to_string(),
4384        folder_hint: folder_hint.to_string(),
4385        redirect_url: redirect_url.to_string(),
4386        server_mode,
4387        csp_nonce: csp_nonce.to_owned(),
4388        version: env!("CARGO_PKG_VERSION"),
4389    }
4390    .render()
4391    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4392    (StatusCode::NOT_FOUND, Html(html)).into_response()
4393}
4394
4395// ── Watched-directory helpers ─────────────────────────────────────────────────
4396
4397/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
4398fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4399    fs::read_dir(dir)
4400        .ok()?
4401        .flatten()
4402        .map(|e| e.path())
4403        .find(|p| {
4404            p.is_file()
4405                && p.extension()
4406                    .and_then(|e| e.to_str())
4407                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4408        })
4409}
4410
4411/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
4412/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
4413/// (`<scan_dir>/json/result*.json`).
4414fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4415    let mut out = Vec::new();
4416    if let Some(j) = find_result_json_in_dir(sub) {
4417        out.push(j);
4418    }
4419    let json_sub = sub.join("json");
4420    if json_sub.is_dir()
4421        && let Some(j) = find_result_json_in_dir(&json_sub)
4422    {
4423        out.push(j);
4424    }
4425    out
4426}
4427
4428fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4429    let mut candidates = Vec::new();
4430    if let Some(j) = find_result_json_in_dir(folder) {
4431        candidates.push(j);
4432    }
4433    let Ok(dir_entries) = fs::read_dir(folder) else {
4434        return candidates;
4435    };
4436    for entry in dir_entries.flatten() {
4437        let sub = entry.path();
4438        if sub.is_dir() {
4439            candidates.extend(subdir_result_json_candidates(&sub));
4440        }
4441    }
4442    candidates
4443}
4444
4445fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4446    reg.entries.iter().any(|e| {
4447        let dir_match = e
4448            .json_path
4449            .as_ref()
4450            .and_then(|p| p.parent())
4451            .is_some_and(|p| p == parent)
4452            || e.html_path
4453                .as_ref()
4454                .and_then(|p| p.parent())
4455                .is_some_and(|p| p == parent);
4456        dir_match
4457            && (e.json_path.as_ref().is_some_and(|p| p.exists())
4458                || e.html_path.as_ref().is_some_and(|p| p.exists()))
4459    })
4460}
4461
4462fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4463    let json_dir = json_path.parent()?.to_path_buf();
4464    // If the JSON lives inside a directory named "json", the scan root is its parent
4465    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
4466    let (html_path, pdf_path, csv_path, xlsx_path) =
4467        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4468            let scan_root = json_dir.parent()?;
4469            let html = find_html_report_in_dir(&scan_root.join("html"))
4470                .or_else(|| find_html_report_in_dir(scan_root));
4471            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4472            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4473            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4474            (html, pdf, csv, xlsx)
4475        } else {
4476            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4477                rd.flatten()
4478                    .map(|e| e.path())
4479                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4480            });
4481            (html, None, None, None)
4482        };
4483    let run = read_json(&json_path).ok()?;
4484    let project_label = run.input_roots.first().map_or_else(
4485        || "Unknown Project".to_string(),
4486        |r| sanitize_project_label(r),
4487    );
4488    Some(RegistryEntry {
4489        run_id: run.tool.run_id.clone(),
4490        timestamp_utc: run.tool.timestamp_utc,
4491        project_label,
4492        input_roots: run.input_roots.clone(),
4493        json_path: Some(json_path),
4494        html_path,
4495        pdf_path,
4496        csv_path,
4497        xlsx_path,
4498        summary: ScanSummarySnapshot::from(&run.summary_totals),
4499        git_branch: run.git_branch.clone(),
4500        git_commit: run.git_commit_short.clone(),
4501        git_commit_long: run.git_commit_long.clone(),
4502        git_author: run.git_commit_author.clone(),
4503        git_tags: run.git_tags.clone(),
4504        git_nearest_tag: run.git_nearest_tag.clone(),
4505        git_commit_date: run.git_commit_date,
4506    })
4507}
4508
4509/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
4510/// Returns the number of newly linked entries.
4511fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4512    let mut linked = 0usize;
4513    for json_path in collect_result_json_candidates(folder) {
4514        let Some(parent) = json_path.parent().map(PathBuf::from) else {
4515            continue;
4516        };
4517        if is_dir_already_registered(reg, &parent) {
4518            continue;
4519        }
4520        let Some(entry) = build_registry_entry_from_json(json_path) else {
4521            continue;
4522        };
4523        reg.add_entry(entry);
4524        linked += 1;
4525    }
4526    linked
4527}
4528
4529/// Scan all watched directories (plus the default output root) into `reg`.
4530async fn auto_scan_watched_dirs(state: &AppState) {
4531    let dirs: Vec<PathBuf> = {
4532        let wd = state.watched_dirs.lock().await;
4533        wd.dirs.clone()
4534    };
4535    // Reconcile the registry to the watched-folder model: keep only entries under a
4536    // currently-watched folder or the app's own output directory. This drops leftovers from
4537    // folders that have since been un-watched (which would otherwise linger in the list).
4538    {
4539        let output_root = resolve_output_root(None);
4540        let mut roots: Vec<PathBuf> = dirs.clone();
4541        if let Ok(canon) = fs::canonicalize(&output_root) {
4542            roots.push(strip_unc_prefix(canon));
4543        }
4544        roots.push(output_root);
4545        let mut reg = state.registry.lock().await;
4546        if reg.retain_under_roots(&roots) > 0 {
4547            let _ = reg.save(&state.registry_path);
4548        }
4549    }
4550    if dirs.is_empty() {
4551        return;
4552    }
4553    let mut reg = state.registry.lock().await;
4554    let mut total = 0usize;
4555    for dir in &dirs {
4556        if dir.is_dir() {
4557            total += scan_folder_into_registry(dir, &mut reg);
4558        }
4559    }
4560    if total > 0 {
4561        let _ = reg.save(&state.registry_path);
4562    }
4563}
4564
4565// ── Watched-dir route forms ───────────────────────────────────────────────────
4566
4567#[derive(Deserialize)]
4568struct WatchedDirForm {
4569    folder_path: String,
4570    #[serde(default = "default_redirect")]
4571    redirect_to: String,
4572}
4573
4574fn default_redirect() -> String {
4575    "/view-reports".to_string()
4576}
4577
4578#[derive(Deserialize)]
4579struct WatchedDirRefreshForm {
4580    #[serde(default = "default_redirect")]
4581    redirect_to: String,
4582}
4583
4584// ── Watched-dir helpers ───────────────────────────────────────────────────────
4585
4586/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
4587fn safe_redirect(dest: &str) -> &str {
4588    if dest.starts_with('/') { dest } else { "/" }
4589}
4590
4591// ── Watched-dir handlers ──────────────────────────────────────────────────────
4592
4593async fn add_watched_dir_handler(
4594    State(state): State<AppState>,
4595    Form(form): Form<WatchedDirForm>,
4596) -> impl IntoResponse {
4597    if state.server_mode {
4598        return StatusCode::NOT_FOUND.into_response();
4599    }
4600    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4601        strip_unc_prefix(p)
4602    } else {
4603        let dest = format!(
4604            "{}?error=Folder+not+found+or+path+is+invalid.",
4605            safe_redirect(&form.redirect_to)
4606        );
4607        return axum::response::Redirect::to(&dest).into_response();
4608    };
4609    if !folder.is_dir() {
4610        let dest = format!(
4611            "{}?error=Selected+path+is+not+a+directory.",
4612            safe_redirect(&form.redirect_to)
4613        );
4614        return axum::response::Redirect::to(&dest).into_response();
4615    }
4616
4617    // Persist the watched directory.
4618    {
4619        let mut wd = state.watched_dirs.lock().await;
4620        wd.add(folder.clone());
4621        let _ = wd.save(&state.watched_dirs_path);
4622    }
4623
4624    // Immediately scan the folder and add any new reports.
4625    let linked = {
4626        let mut reg = state.registry.lock().await;
4627        let n = scan_folder_into_registry(&folder, &mut reg);
4628        if n > 0 {
4629            let _ = reg.save(&state.registry_path);
4630        }
4631        n
4632    };
4633
4634    let dest = if linked > 0 {
4635        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4636    } else {
4637        format!(
4638            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4639            safe_redirect(&form.redirect_to)
4640        )
4641    };
4642    axum::response::Redirect::to(&dest).into_response()
4643}
4644
4645async fn remove_watched_dir_handler(
4646    State(state): State<AppState>,
4647    Form(form): Form<WatchedDirForm>,
4648) -> impl IntoResponse {
4649    if state.server_mode {
4650        return StatusCode::NOT_FOUND.into_response();
4651    }
4652    let folder = PathBuf::from(&form.folder_path);
4653    {
4654        let mut wd = state.watched_dirs.lock().await;
4655        wd.remove(&folder);
4656        let _ = wd.save(&state.watched_dirs_path);
4657    }
4658    // Drop any reports that were linked in from this folder so the list reflects the removal.
4659    {
4660        let mut reg = state.registry.lock().await;
4661        if reg.remove_entries_under(&folder) > 0 {
4662            let _ = reg.save(&state.registry_path);
4663        }
4664    }
4665    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4666}
4667
4668async fn refresh_watched_dirs_handler(
4669    State(state): State<AppState>,
4670    Form(form): Form<WatchedDirRefreshForm>,
4671) -> impl IntoResponse {
4672    if state.server_mode {
4673        return StatusCode::NOT_FOUND.into_response();
4674    }
4675    let dirs: Vec<PathBuf> = {
4676        let wd = state.watched_dirs.lock().await;
4677        wd.dirs.clone()
4678    };
4679    let mut total = 0usize;
4680    {
4681        let mut reg = state.registry.lock().await;
4682        reg.prune_stale();
4683        for dir in &dirs {
4684            if dir.is_dir() {
4685                total += scan_folder_into_registry(dir, &mut reg);
4686            }
4687        }
4688        let _ = reg.save(&state.registry_path);
4689    }
4690    let dest = if total > 0 {
4691        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4692    } else {
4693        safe_redirect(&form.redirect_to).to_owned()
4694    };
4695    axum::response::Redirect::to(&dest).into_response()
4696}
4697
4698#[derive(Debug, Deserialize)]
4699struct OpenPathQuery {
4700    path: Option<String>,
4701}
4702
4703fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4704    let mut ancestor = std::path::Path::new(raw);
4705    loop {
4706        match ancestor.parent() {
4707            Some(p) => {
4708                ancestor = p;
4709                if ancestor.is_dir() {
4710                    break;
4711                }
4712            }
4713            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4714        }
4715    }
4716    Ok(ancestor.to_path_buf())
4717}
4718
4719async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4720    match tokio::fs::canonicalize(raw).await {
4721        Ok(canonical) if canonical.is_file() => canonical
4722            .parent()
4723            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4724                Ok(p.to_path_buf())
4725            }),
4726        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4727        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4728        Err(_) => find_existing_ancestor(raw),
4729    }
4730}
4731
4732async fn open_path_handler(
4733    State(state): State<AppState>,
4734    Query(query): Query<OpenPathQuery>,
4735) -> impl IntoResponse {
4736    if state.server_mode {
4737        return Json(serde_json::json!({
4738            "server_mode_disabled": true,
4739            "message": "Opening a path in the file manager is only available in local desktop mode."
4740        }))
4741        .into_response();
4742    }
4743    // Skip the OS file-manager call in headless / CI environments.
4744    if std::env::var("SLOC_HEADLESS").is_ok() {
4745        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4746    }
4747    let raw = match query.path.as_deref() {
4748        Some(p) if !p.is_empty() => p,
4749        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4750    };
4751
4752    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4753    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4754    // so the file explorer still opens somewhere useful.
4755    let target = match resolve_open_target(raw).await {
4756        Ok(p) => p,
4757        Err((code, msg)) => return (code, msg).into_response(),
4758    };
4759
4760    #[cfg(target_os = "windows")]
4761    win_dialog_focus::open_folder_foreground(target);
4762    #[cfg(target_os = "macos")]
4763    let _ = std::process::Command::new("open")
4764        .arg(&target)
4765        .stdout(Stdio::null())
4766        .stderr(Stdio::null())
4767        .spawn();
4768    #[cfg(target_os = "linux")]
4769    {
4770        let folder_name = target
4771            .file_name()
4772            .and_then(|n| n.to_str())
4773            .map(str::to_owned);
4774        let _ = std::process::Command::new("xdg-open")
4775            .arg(&target)
4776            .stdout(Stdio::null())
4777            .stderr(Stdio::null())
4778            .spawn();
4779        // Best-effort: raise the file manager window once it appears.
4780        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4781        // installed; failures are silently discarded.
4782        if let Some(name) = folder_name {
4783            std::thread::spawn(move || {
4784                std::thread::sleep(std::time::Duration::from_millis(800));
4785                let _ = std::process::Command::new("wmctrl")
4786                    .args(["-a", &name])
4787                    .stdout(Stdio::null())
4788                    .stderr(Stdio::null())
4789                    .spawn();
4790            });
4791        }
4792    }
4793
4794    Json(serde_json::json!({"ok": true})).into_response()
4795}
4796
4797async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4798    let (content_type, bytes): (&'static str, &'static [u8]) =
4799        match (folder.as_str(), file.as_str()) {
4800            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4801            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4802            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4803            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4804            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4805            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4806            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4807            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4808            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4809            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4810            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4811            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4812            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4813            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4814            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4815            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4816            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4817            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4818            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4819            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4820            _ => return StatusCode::NOT_FOUND.into_response(),
4821        };
4822    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4823}
4824
4825/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4826/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4827/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4828/// complexity low; the fail-closed semantics are unchanged.
4829fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4830    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4831    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4832    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4833    // sample/upload locations are permitted; everything else is rejected.
4834    let Ok(canonical) = fs::canonicalize(resolved) else {
4835        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4836            return Err(Html(
4837                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4838            ));
4839        }
4840        return Ok(());
4841    };
4842    // Upload temp dirs and built-in sample/fixture paths are always safe.
4843    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4844        return Ok(());
4845    }
4846    let config = &state.base_config;
4847    if config.discovery.allowed_scan_roots.is_empty() {
4848        return Err(Html(
4849            r#"<div class="preview-error">Preview rejected: this server has no scan roots configured. Set SLOC_ALLOWED_ROOTS (colon-separated paths) to enable server-side path scanning; the Browse / upload flow works without it.</div>"#.to_string()
4850        ));
4851    }
4852    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4853        fs::canonicalize(root)
4854            .ok()
4855            .is_some_and(|r| canonical.starts_with(&r))
4856    });
4857    if !allowed {
4858        return Err(Html(
4859            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4860        ));
4861    }
4862    Ok(())
4863}
4864
4865async fn preview_handler(
4866    State(state): State<AppState>,
4867    Query(query): Query<PreviewQuery>,
4868) -> impl IntoResponse {
4869    let raw_path = query
4870        .path
4871        .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4872    let resolved = resolve_input_path(&raw_path);
4873
4874    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4875    // binary whose working directory is not the project root), return a clear message
4876    // instead of an opaque OS error from build_preview_html.
4877    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4878        return Html(
4879            r#"<div class="preview-error">Sample directory not available on this server.
4880            Enter a path to a project directory or upload files using Browse.</div>"#
4881                .to_string(),
4882        );
4883    }
4884
4885    if state.server_mode
4886        && let Err(resp) = authorize_preview_path(&state, &resolved)
4887    {
4888        return resp;
4889    }
4890
4891    let include_patterns = split_patterns(query.include_globs.as_deref());
4892    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4893
4894    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4895        Ok(html) => Html(html),
4896        Err(err) => Html(format!(
4897            r#"<div class="preview-error">Preview failed: {}</div>"#,
4898            escape_html(&err.to_string())
4899        )),
4900    }
4901}
4902
4903#[derive(Debug, Deserialize, Default)]
4904struct SuggestCoverageQuery {
4905    path: Option<String>,
4906}
4907
4908#[derive(Serialize)]
4909struct SuggestCoverageResponse {
4910    found: Option<String>,
4911    tool: Option<&'static str>,
4912    hint: Option<&'static str>,
4913}
4914
4915async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4916    const CANDIDATES: &[&str] = &[
4917        // LCOV — cargo-llvm-cov, gcov, lcov
4918        "coverage/lcov.info",
4919        "lcov.info",
4920        "target/llvm-cov/lcov.info",
4921        "target/coverage/lcov.info",
4922        "target/debug/coverage/lcov.info",
4923        "coverage/coverage.lcov",
4924        "build/coverage/lcov.info",
4925        "reports/lcov.info",
4926        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4927        "coverage.xml",
4928        "coverage/coverage.xml",
4929        "target/site/cobertura/coverage.xml",
4930        "build/reports/coverage/coverage.xml",
4931        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4932        "target/site/jacoco/jacoco.xml",
4933        "build/reports/jacoco/test/jacocoTestReport.xml",
4934        "build/reports/jacoco/jacocoTestReport.xml",
4935        "build/jacoco/jacoco.xml",
4936        // coverage.py native JSON — `coverage json`
4937        "coverage.json",
4938        "coverage/coverage.json",
4939    ];
4940    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4941    let found = CANDIDATES
4942        .iter()
4943        .map(|rel| root.join(rel))
4944        .find(|p| p.is_file())
4945        .map(|p| display_path(&p));
4946
4947    let (tool, hint) = detect_coverage_tool(&root);
4948    Json(SuggestCoverageResponse { found, tool, hint })
4949}
4950
4951/// Inspect the project root for known build/package files and return the most likely coverage
4952/// tool name and the shell command needed to generate a coverage file.
4953fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4954    if root.join("Cargo.toml").is_file() {
4955        return (
4956            Some("cargo-llvm-cov"),
4957            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4958        );
4959    }
4960    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4961        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4962    }
4963    if root.join("pom.xml").is_file() {
4964        return (Some("jacoco"), Some("mvn test jacoco:report"));
4965    }
4966    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4967        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4968    }
4969    (None, None)
4970}
4971
4972/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4973#[allow(clippy::result_large_err)]
4974fn validate_server_scan_path(
4975    config: &sloc_config::AppConfig,
4976    resolved_path: &Path,
4977    csp_nonce: &str,
4978) -> Result<(), Response> {
4979    if config.discovery.allowed_scan_roots.is_empty() {
4980        let template = ErrorTemplate {
4981            message: "Scan path rejected: this server has no scan roots configured, so \
4982                      scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4983                      environment variable (colon-separated absolute paths) — or \
4984                      allowed_scan_roots in the config TOML — then restart. Tip: the \
4985                      Browse / directory-upload flow works without this; uploaded folders \
4986                      are scanned from the server's temp area and bypass this check."
4987                .to_string(),
4988            last_report_url: None,
4989            last_report_label: None,
4990            run_id: None,
4991            error_code: Some(403),
4992            csp_nonce: csp_nonce.to_owned(),
4993            version: env!("CARGO_PKG_VERSION"),
4994        };
4995        return Err((
4996            StatusCode::FORBIDDEN,
4997            Html(
4998                template
4999                    .render()
5000                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5001            ),
5002        )
5003            .into_response());
5004    }
5005    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
5006    // location) we must NOT fall back to the raw, un-normalised path — a textual
5007    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
5008    // allowlist. A non-resolvable scan target is rejected outright.
5009    let Ok(canonical) = fs::canonicalize(resolved_path) else {
5010        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
5011            "Scan path does not resolve to a real location");
5012        let template = ErrorTemplate {
5013            message: "The requested path could not be resolved to a real directory.".to_string(),
5014            last_report_url: None,
5015            last_report_label: None,
5016            run_id: None,
5017            error_code: Some(403),
5018            csp_nonce: csp_nonce.to_owned(),
5019            version: env!("CARGO_PKG_VERSION"),
5020        };
5021        return Err((
5022            StatusCode::FORBIDDEN,
5023            Html(
5024                template
5025                    .render()
5026                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5027            ),
5028        )
5029            .into_response());
5030    };
5031    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
5032        fs::canonicalize(root)
5033            .ok()
5034            .is_some_and(|r| canonical.starts_with(&r))
5035    });
5036    if !allowed {
5037        tracing::warn!(event = "path_rejected", path = %canonical.display(),
5038            "Scan path not in allowed_scan_roots");
5039        let template = ErrorTemplate {
5040            message: "The requested path is not within an allowed scan directory.".to_string(),
5041            last_report_url: None,
5042            last_report_label: None,
5043            run_id: None,
5044            error_code: Some(403),
5045            csp_nonce: csp_nonce.to_owned(),
5046            version: env!("CARGO_PKG_VERSION"),
5047        };
5048        return Err((
5049            StatusCode::FORBIDDEN,
5050            Html(
5051                template
5052                    .render()
5053                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
5054            ),
5055        )
5056            .into_response());
5057    }
5058    Ok(())
5059}
5060
5061/// Exclude the output directory from scanning so artifacts don't pollute counts.
5062fn apply_output_dir_exclusions(
5063    config: &mut sloc_config::AppConfig,
5064    project_path: &str,
5065    raw_output_dir: &str,
5066) {
5067    let project_root = resolve_input_path(project_path);
5068    let raw_out = raw_output_dir.trim();
5069    let resolved_out = if raw_out.is_empty() {
5070        project_root.join("sloc")
5071    } else if Path::new(raw_out).is_absolute() {
5072        PathBuf::from(raw_out)
5073    } else {
5074        workspace_root().join(raw_out)
5075    };
5076    if let Ok(rel) = resolved_out.strip_prefix(&project_root)
5077        && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
5078    {
5079        let dir = first.to_string();
5080        if !config.discovery.excluded_directories.contains(&dir) {
5081            config.discovery.excluded_directories.push(dir);
5082        }
5083    }
5084    if !config
5085        .discovery
5086        .excluded_directories
5087        .iter()
5088        .any(|d| d == "sloc")
5089    {
5090        config
5091            .discovery
5092            .excluded_directories
5093            .push("sloc".to_string());
5094    }
5095}
5096
5097/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
5098const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
5099    ScanSummarySnapshot {
5100        files_analyzed: run.summary_totals.files_analyzed,
5101        files_skipped: run.summary_totals.files_skipped,
5102        total_physical_lines: run.summary_totals.total_physical_lines,
5103        code_lines: run.summary_totals.code_lines,
5104        comment_lines: run.summary_totals.comment_lines,
5105        blank_lines: run.summary_totals.blank_lines,
5106        functions: run.summary_totals.functions,
5107        classes: run.summary_totals.classes,
5108        variables: run.summary_totals.variables,
5109        imports: run.summary_totals.imports,
5110        test_count: run.summary_totals.test_count,
5111        coverage_lines_found: run.summary_totals.coverage_lines_found,
5112        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
5113        coverage_functions_found: run.summary_totals.coverage_functions_found,
5114        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
5115        coverage_branches_found: run.summary_totals.coverage_branches_found,
5116        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
5117    }
5118}
5119
5120/// Build the `RegistryEntry` for the just-completed scan run.
5121pub(crate) fn build_run_registry_entry(
5122    run: &AnalysisRun,
5123    run_id: &str,
5124    project_label: &str,
5125    artifacts: &RunArtifacts,
5126) -> RegistryEntry {
5127    RegistryEntry {
5128        run_id: run_id.to_owned(),
5129        timestamp_utc: run.tool.timestamp_utc,
5130        project_label: project_label.to_owned(),
5131        input_roots: run.input_roots.clone(),
5132        json_path: artifacts.json_path.clone(),
5133        html_path: artifacts.html_path.clone(),
5134        pdf_path: artifacts.pdf_path.clone(),
5135        csv_path: artifacts.csv_path.clone(),
5136        xlsx_path: artifacts.xlsx_path.clone(),
5137        summary: summary_snapshot_from_run(run),
5138        git_branch: run.git_branch.clone(),
5139        git_commit: run.git_commit_short.clone(),
5140        git_commit_long: run.git_commit_long.clone(),
5141        git_author: run.git_commit_author.clone(),
5142        git_tags: run.git_tags.clone(),
5143        git_nearest_tag: run.git_nearest_tag.clone(),
5144        git_commit_date: run.git_commit_date.clone(),
5145    }
5146}
5147
5148/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
5149fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5150    if let Some(policy) = form.mixed_line_policy {
5151        config.analysis.mixed_line_policy = policy;
5152    }
5153    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
5154    config.analysis.generated_file_detection =
5155        form.generated_file_detection.as_deref() != Some("disabled");
5156    config.analysis.minified_file_detection =
5157        form.minified_file_detection.as_deref() != Some("disabled");
5158    config.analysis.vendor_directory_detection =
5159        form.vendor_directory_detection.as_deref() != Some("disabled");
5160    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
5161    if let Some(binary_behavior) = form.binary_file_behavior {
5162        config.analysis.binary_file_behavior = binary_behavior;
5163    }
5164    apply_report_opts(config, form);
5165    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
5166    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
5167    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
5168    if let Some(policy) = form.continuation_line_policy {
5169        config.analysis.continuation_line_policy = policy;
5170    }
5171    if let Some(policy) = form.blank_in_block_comment_policy {
5172        config.analysis.blank_in_block_comment_policy = policy;
5173    }
5174    config.analysis.count_compiler_directives =
5175        form.count_compiler_directives.as_deref() != Some("disabled");
5176    apply_style_threshold(config, form);
5177    apply_coverage_path(config, form);
5178}
5179
5180fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5181    if let Some(report_title) = form.report_title.as_deref() {
5182        let trimmed = report_title.trim();
5183        if !trimmed.is_empty() {
5184            config.reporting.report_title = trimmed.to_string();
5185        }
5186    }
5187    if let Some(hf) = form.report_header_footer.as_deref() {
5188        let trimmed = hf.trim();
5189        config.reporting.report_header_footer = if trimmed.is_empty() {
5190            None
5191        } else {
5192            Some(trimmed.to_string())
5193        };
5194    }
5195}
5196
5197fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5198    apply_style_col_threshold(config, form);
5199    apply_style_analysis_enabled(config, form);
5200    apply_style_score_threshold(config, form);
5201    apply_style_lang_scope(config, form);
5202    apply_activity_window(config, form);
5203}
5204
5205fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5206    if let Some(threshold_str) = form.style_col_threshold.as_deref()
5207        && let Ok(t) = threshold_str.parse::<u16>()
5208        && (t == 80 || t == 100 || t == 120)
5209    {
5210        config.analysis.style_col_threshold = t;
5211    }
5212}
5213
5214fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5215    if let Some(v) = form.style_analysis_enabled.as_deref() {
5216        config.analysis.style_analysis_enabled = v != "disabled";
5217    }
5218}
5219
5220fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5221    if let Some(v) = form.style_score_threshold.as_deref()
5222        && let Ok(t) = v.parse::<u8>()
5223    {
5224        config.analysis.style_score_threshold = t.min(100);
5225    }
5226}
5227
5228fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5229    if let Some(v) = form.style_lang_scope.as_deref() {
5230        let scope = v.trim();
5231        if scope == "c_family" || scope == "all" {
5232            config.analysis.style_lang_scope = scope.to_string();
5233        }
5234    }
5235}
5236
5237fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5238    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
5239    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
5240    if let Some(w) = form.activity_window.as_deref() {
5241        let w = w.trim();
5242        if !w.is_empty()
5243            && let Ok(days) = w.parse::<u32>()
5244        {
5245            config.analysis.activity_window_days = Some(days);
5246        }
5247    }
5248}
5249
5250fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5251    if let Some(cov) = &form.coverage_file {
5252        let trimmed = cov.trim();
5253        if !trimmed.is_empty() {
5254            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5255        }
5256    }
5257}
5258
5259/// Fire-and-forget: generate the PDF in a background task if one is pending.
5260/// On failure, clears `pdf_path` in the artifacts map so the results page shows
5261/// an error instead of spinning indefinitely.
5262fn spawn_pdf_background(
5263    pending_pdf: PendingPdf,
5264    run_id: String,
5265    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5266) {
5267    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5268        tokio::spawn(async move {
5269            let result = tokio::task::spawn_blocking(move || {
5270                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5271                if cleanup_src {
5272                    let _ = fs::remove_file(&pdf_src);
5273                }
5274                r
5275            })
5276            .await;
5277            let failed = match result {
5278                Ok(Ok(())) => false,
5279                Ok(Err(err)) => {
5280                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5281                    true
5282                }
5283                Err(err) => {
5284                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5285                    true
5286                }
5287            };
5288            if failed {
5289                let mut map = artifacts.lock().await;
5290                if let Some(entry) = map.get_mut(&run_id) {
5291                    entry.pdf_path = None;
5292                }
5293            }
5294        });
5295    }
5296}
5297
5298/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
5299/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
5300/// result page can show an error on the next visit instead of spinning indefinitely.
5301fn spawn_native_pdf_background(
5302    json_path: PathBuf,
5303    pdf_dest: PathBuf,
5304    run_id: String,
5305    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5306) {
5307    tokio::spawn(async move {
5308        let result = tokio::task::spawn_blocking(move || {
5309            let run = sloc_core::read_json(&json_path)?;
5310            write_pdf_from_run(&run, &pdf_dest)
5311        })
5312        .await;
5313        let failed = match result {
5314            Ok(Ok(())) => false,
5315            Ok(Err(err)) => {
5316                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5317                true
5318            }
5319            Err(err) => {
5320                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5321                true
5322            }
5323        };
5324        if failed {
5325            let mut map = artifacts.lock().await;
5326            if let Some(entry) = map.get_mut(&run_id) {
5327                entry.pdf_path = None;
5328            }
5329        }
5330    });
5331}
5332
5333/// Sum the code lines added in this comparison (new + grown files).
5334fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5335    cmp.file_deltas
5336        .iter()
5337        .map(|f| match f.status {
5338            FileChangeStatus::Added => f.current_code,
5339            FileChangeStatus::Modified => f.code_delta.max(0),
5340            _ => 0,
5341        })
5342        .sum()
5343}
5344
5345/// Sum the code lines removed in this comparison (deleted + shrunk files).
5346fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5347    cmp.file_deltas
5348        .iter()
5349        .map(|f| match f.status {
5350            FileChangeStatus::Removed => f.baseline_code,
5351            FileChangeStatus::Modified => (-f.code_delta).max(0),
5352            _ => 0,
5353        })
5354        .sum()
5355}
5356
5357/// Sum the code lines present in both scans without any change (Unchanged files).
5358fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5359    cmp.file_deltas
5360        .iter()
5361        .filter(|f| f.status == FileChangeStatus::Unchanged)
5362        .map(|f| f.current_code)
5363        .sum()
5364}
5365
5366/// Sum the code lines residing in files that were modified between the two scans.
5367fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5368    cmp.file_deltas
5369        .iter()
5370        .filter(|f| f.status == FileChangeStatus::Modified)
5371        .map(|f| f.current_code)
5372        .sum()
5373}
5374
5375/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
5376fn build_submodule_row(
5377    s: &sloc_core::SubmoduleSummary,
5378    run: &AnalysisRun,
5379    run_id: &str,
5380    run_dir: &Path,
5381) -> SubmoduleRow {
5382    let safe = sanitize_project_label(&s.name);
5383    let artifact_key = format!("sub_{safe}");
5384    let pdf_artifact_key = format!("sub_{safe}_pdf");
5385    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5386        let parent_path = run
5387            .input_roots
5388            .first()
5389            .map_or("", std::string::String::as_str);
5390        let sub_run = build_sub_run(run, s, parent_path);
5391        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5392        render_sub_report_html(&sub_run, Some(&pdf_server_url))
5393            .ok()
5394            .and_then(|sub_html| {
5395                let sub_dir = run_dir.join("submodules");
5396                let _ = fs::create_dir_all(&sub_dir);
5397                let html_path = sub_dir.join(format!("{artifact_key}.html"));
5398                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5399                    // Pre-generate the sub-report PDF using the programmatic renderer
5400                    // so "View PDF" never needs to spawn Chrome for submodules.
5401                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5402                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
5403                    Some(format!("/runs/{artifact_key}/{run_id}"))
5404                } else {
5405                    None
5406                }
5407            })
5408    } else {
5409        None
5410    };
5411    SubmoduleRow {
5412        name: s.name.clone(),
5413        relative_path: s.relative_path.clone(),
5414        files_analyzed: s.files_analyzed,
5415        code_lines: s.code_lines,
5416        comment_lines: s.comment_lines,
5417        blank_lines: s.blank_lines,
5418        total_physical_lines: s.total_physical_lines,
5419        html_url,
5420    }
5421}
5422
5423// Immediately returns a wait page and runs the analysis in a background tokio task.
5424// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
5425#[allow(clippy::similar_names)]
5426#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
5427#[allow(clippy::too_many_lines)]
5428async fn analyze_handler(
5429    State(state): State<AppState>,
5430    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5431    Form(form): Form<AnalyzeForm>,
5432) -> impl IntoResponse {
5433    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5434        let template = ErrorTemplate {
5435            message: format!(
5436                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5437             Please wait a moment and try again."
5438            ),
5439            last_report_url: None,
5440            last_report_label: None,
5441            run_id: None,
5442            error_code: Some(503),
5443            csp_nonce: csp_nonce.clone(),
5444            version: env!("CARGO_PKG_VERSION"),
5445        };
5446        return (
5447            StatusCode::SERVICE_UNAVAILABLE,
5448            Html(
5449                template
5450                    .render()
5451                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5452            ),
5453        )
5454            .into_response();
5455    };
5456
5457    let mut config = state.base_config.clone();
5458
5459    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5460    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5461    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5462
5463    if !is_git_mode {
5464        let resolved_path = resolve_input_path(&form.path);
5465        if state.server_mode
5466            && !is_upload_tmp_path(&resolved_path)
5467            && !is_sample_path(&resolved_path)
5468            && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
5469        {
5470            return resp;
5471        }
5472        config.discovery.root_paths = vec![resolved_path];
5473    }
5474
5475    apply_form_to_config(&mut config, &form);
5476    apply_output_dir_exclusions(
5477        &mut config,
5478        &form.path,
5479        form.output_dir.as_deref().unwrap_or(""),
5480    );
5481
5482    // Generate a wait_id now (before spawning) so the client can poll for status.
5483    let wait_id = uuid::Uuid::new_v4().to_string();
5484    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5485
5486    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
5487    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5488    let task_cancel = Arc::clone(&cancel_token);
5489
5490    // Phase tracker: updated by run_analysis_task at key checkpoints.
5491    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5492    let task_phase = Arc::clone(&phase);
5493
5494    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5495    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5496    let task_files_done = Arc::clone(&files_done);
5497    let task_files_total = Arc::clone(&files_total);
5498
5499    // Register Running state before building the task struct so the semaphore permit
5500    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
5501    {
5502        let mut runs = state.async_runs.lock().await;
5503        runs.insert(
5504            wait_id.clone(),
5505            AsyncRunState::Running {
5506                started_at: std::time::Instant::now(),
5507                cancel_token,
5508                phase,
5509                files_done,
5510                files_total,
5511            },
5512        );
5513    }
5514
5515    let task = AnalysisTask {
5516        sem_permit,
5517        state: state.clone(),
5518        wait_id: wait_id.clone(),
5519        config,
5520        cancel: task_cancel,
5521        phase: task_phase,
5522        files_done: task_files_done,
5523        files_total: task_files_total,
5524        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5525        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5526        project_path: form.path.clone(),
5527        // In server mode the client-supplied output_dir is ignored — artifacts are
5528        // always written under the server's configured output root so remote users
5529        // cannot direct writes to arbitrary filesystem paths.
5530        output_dir: if state.server_mode {
5531            None
5532        } else {
5533            form.output_dir.clone()
5534        },
5535        clones_dir: state.git_clones_dir.clone(),
5536        cocomo_mode: form
5537            .cocomo_mode
5538            .clone()
5539            .unwrap_or_else(|| "organic".to_string()),
5540        complexity_alert: form
5541            .complexity_alert
5542            .as_deref()
5543            .and_then(|s| s.parse::<u32>().ok())
5544            .unwrap_or(0),
5545        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5546    };
5547
5548    tokio::spawn(run_analysis_task(task));
5549
5550    let template = ScanWaitTemplate {
5551        version: env!("CARGO_PKG_VERSION"),
5552        wait_id_json,
5553        project_path: form.path.clone(),
5554        csp_nonce,
5555    };
5556    let html = template
5557        .render()
5558        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5559    let mut response = Html(html).into_response();
5560    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
5561        && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
5562    {
5563        response.headers_mut().insert(name, val);
5564    }
5565    response
5566}
5567
5568struct AnalysisTask {
5569    sem_permit: tokio::sync::OwnedSemaphorePermit,
5570    state: AppState,
5571    wait_id: String,
5572    config: AppConfig,
5573    cancel: Arc<std::sync::atomic::AtomicBool>,
5574    phase: Arc<std::sync::Mutex<String>>,
5575    files_done: Arc<std::sync::atomic::AtomicUsize>,
5576    files_total: Arc<std::sync::atomic::AtomicUsize>,
5577    git_repo: Option<String>,
5578    git_ref: Option<String>,
5579    project_path: String,
5580    output_dir: Option<String>,
5581    clones_dir: PathBuf,
5582    cocomo_mode: String,
5583    complexity_alert: u32,
5584    exclude_duplicates: bool,
5585}
5586
5587#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
5588async fn run_analysis_task(task: AnalysisTask) {
5589    let _permit = task.sem_permit;
5590
5591    let cancel_sb = Arc::clone(&task.cancel);
5592    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5593    let clones_dir_sb = task.clones_dir;
5594    // Save the upload staging path before config is moved into spawn_blocking.
5595    let upload_staging_root = task
5596        .config
5597        .discovery
5598        .root_paths
5599        .first()
5600        .filter(|p| is_upload_tmp_path(p))
5601        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5602        .map(PathBuf::from);
5603    let config_sb = task.config;
5604    let progress_sb = sloc_core::ProgressCounters {
5605        files_done: Arc::clone(&task.files_done),
5606        files_total: Arc::clone(&task.files_total),
5607    };
5608    if let Ok(mut p) = task.phase.lock() {
5609        *p = "Scanning files".to_string();
5610    }
5611    let analysis_result = tokio::task::spawn_blocking(move || {
5612        run_analysis_blocking(
5613            config_sb,
5614            git_repo_sb,
5615            git_ref_sb,
5616            clones_dir_sb,
5617            cancel_sb,
5618            Some(progress_sb),
5619        )
5620    })
5621    .await
5622    .map_err(|err| anyhow::anyhow!(err.to_string()))
5623    .and_then(|result| result);
5624
5625    if let Ok(mut p) = task.phase.lock() {
5626        *p = "Writing reports".to_string();
5627    }
5628
5629    // If cancelled while running, discard results and mark as cancelled.
5630    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5631        let mut runs = task.state.async_runs.lock().await;
5632        // Only overwrite if still Running (don't clobber a Complete that snuck in).
5633        if matches!(
5634            runs.get(&task.wait_id),
5635            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5636        ) {
5637            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5638        }
5639        drop(runs);
5640        return;
5641    }
5642
5643    let run = match analysis_result {
5644        Ok(v) => v,
5645        Err(err) => {
5646            // Distinguish user-cancelled from real failure.
5647            if err.to_string().contains("analysis cancelled") {
5648                let mut runs = task.state.async_runs.lock().await;
5649                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5650                drop(runs);
5651                return;
5652            }
5653            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5654            let mut runs = task.state.async_runs.lock().await;
5655            runs.insert(
5656                task.wait_id.clone(),
5657                AsyncRunState::Failed {
5658                    message: "Analysis failed. Check that the path exists and is readable."
5659                        .to_string(),
5660                },
5661            );
5662            drop(runs);
5663            return;
5664        }
5665    };
5666
5667    let run_id = run.tool.run_id.clone();
5668    tracing::info!(event = "scan_complete", run_id = %run_id,
5669        path = %task.project_path, files = run.summary_totals.files_analyzed,
5670        "Analysis finished");
5671
5672    let prev_entry: Option<RegistryEntry> = {
5673        let reg = task.state.registry.lock().await;
5674        reg.entries_for_roots(&run.input_roots)
5675            .into_iter()
5676            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5677            .cloned()
5678    };
5679
5680    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5681        prev.json_path
5682            .as_ref()
5683            .and_then(|p| read_json(p).ok())
5684            .map(|prev_run| compute_delta(&prev_run, &run))
5685    });
5686    let prev_scan_count: usize = {
5687        let reg = task.state.registry.lock().await;
5688        reg.entries_for_roots(&run.input_roots)
5689            .iter()
5690            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5691            .count()
5692    };
5693
5694    // Build the HTML report now that delta is available, so the artifact
5695    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5696    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5697        .as_ref()
5698        .zip(prev_entry.as_ref())
5699        .map(|(cmp, prev)| ReportDeltaContext {
5700            delta_code_added: sum_added_code_lines(cmp),
5701            delta_code_removed: sum_removed_code_lines(cmp),
5702            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5703            delta_files_added: cmp.files_added,
5704            delta_files_removed: cmp.files_removed,
5705            delta_files_modified: cmp.files_modified,
5706            delta_files_unchanged: cmp.files_unchanged,
5707            prev_code_lines: prev.summary.code_lines,
5708            prev_scan_count: prev_scan_count + 1,
5709            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5710            prev_run_id: Some(prev.run_id.clone()),
5711            current_run_id: Some(run_id.clone()),
5712        });
5713    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5714        Ok(h) => h,
5715        Err(err) => {
5716            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5717            let mut runs = task.state.async_runs.lock().await;
5718            runs.insert(
5719                task.wait_id.clone(),
5720                AsyncRunState::Failed {
5721                    message: "Failed to render HTML report.".to_string(),
5722                },
5723            );
5724            drop(runs);
5725            return;
5726        }
5727    };
5728
5729    let output_root = resolve_output_root(task.output_dir.as_deref());
5730    let project_label = derive_project_label(
5731        task.git_repo.as_deref(),
5732        task.git_ref.as_deref(),
5733        &task.project_path,
5734    );
5735    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5736    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5737
5738    let result_context = RunResultContext {
5739        prev_entry: prev_entry.clone(),
5740        prev_scan_count,
5741        project_path: task.project_path.clone(),
5742        cocomo_mode: task.cocomo_mode.clone(),
5743        complexity_alert: task.complexity_alert,
5744        exclude_duplicates: task.exclude_duplicates,
5745    };
5746
5747    let artifact_result = persist_run_artifacts(
5748        &run,
5749        &report_html,
5750        &run_dir,
5751        &run.effective_configuration.reporting.report_title,
5752        &file_stem,
5753        result_context,
5754    );
5755
5756    let (artifacts, pending_pdf) = match artifact_result {
5757        Ok(v) => v,
5758        Err(err) => {
5759            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5760            let mut runs = task.state.async_runs.lock().await;
5761            runs.insert(
5762                task.wait_id.clone(),
5763                AsyncRunState::Failed {
5764                    message: "Failed to save report artifacts. Check available disk space."
5765                        .to_string(),
5766                },
5767            );
5768            drop(runs);
5769            return;
5770        }
5771    };
5772
5773    {
5774        let mut map = task.state.artifacts.lock().await;
5775        map.insert(run_id.clone(), artifacts.clone());
5776    }
5777
5778    {
5779        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5780        let mut reg = task.state.registry.lock().await;
5781        reg.add_entry(entry);
5782        let _ = reg.save(&task.state.registry_path);
5783    }
5784
5785    if let Some(ref cfg_path) = artifacts.scan_config_path {
5786        save_scan_config_json(
5787            cfg_path,
5788            &run,
5789            &task.project_path,
5790            task.output_dir.as_deref(),
5791            &task.cocomo_mode,
5792            task.complexity_alert,
5793            task.exclude_duplicates,
5794        );
5795    }
5796
5797    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5798
5799    prom_runs_total().inc();
5800
5801    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5802    let mut runs = task.state.async_runs.lock().await;
5803    runs.insert(
5804        task.wait_id.clone(),
5805        AsyncRunState::Complete {
5806            run_id: run_id.clone(),
5807        },
5808    );
5809    drop(runs);
5810
5811    // Remove the client-upload staging directory after a successful scan so
5812    // that uploaded project files don't accumulate in the OS temp directory.
5813    if let Some(staging) = upload_staging_root {
5814        let _ = tokio::fs::remove_dir_all(staging).await;
5815    }
5816
5817    let _ = scan_delta;
5818}
5819
5820fn save_scan_config_json(
5821    cfg_path: &std::path::Path,
5822    run: &sloc_core::AnalysisRun,
5823    project_path: &str,
5824    output_dir: Option<&str>,
5825    cocomo_mode: &str,
5826    complexity_alert: u32,
5827    exclude_duplicates: bool,
5828) {
5829    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5830        .ok()
5831        .and_then(|v| v.as_str().map(String::from))
5832        .unwrap_or_else(|| "code_only".to_string());
5833    let behavior_str =
5834        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5835            .ok()
5836            .and_then(|v| v.as_str().map(String::from))
5837            .unwrap_or_else(|| "skip".to_string());
5838    let continuation_policy_str = serde_json::to_value(
5839        run.effective_configuration
5840            .analysis
5841            .continuation_line_policy,
5842    )
5843    .ok()
5844    .and_then(|v| v.as_str().map(String::from))
5845    .unwrap_or_else(default_each_physical_line);
5846    let blank_policy_str = serde_json::to_value(
5847        run.effective_configuration
5848            .analysis
5849            .blank_in_block_comment_policy,
5850    )
5851    .ok()
5852    .and_then(|v| v.as_str().map(String::from))
5853    .unwrap_or_else(default_count_as_comment);
5854    let scan_cfg = ScanConfig {
5855        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5856        path: project_path.to_string(),
5857        include_globs: run
5858            .effective_configuration
5859            .discovery
5860            .include_globs
5861            .join("\n"),
5862        exclude_globs: run
5863            .effective_configuration
5864            .discovery
5865            .exclude_globs
5866            .join("\n"),
5867        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5868        mixed_line_policy: policy_str,
5869        python_docstrings_as_comments: run
5870            .effective_configuration
5871            .analysis
5872            .python_docstrings_as_comments,
5873        generated_file_detection: run
5874            .effective_configuration
5875            .analysis
5876            .generated_file_detection,
5877        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5878        vendor_directory_detection: run
5879            .effective_configuration
5880            .analysis
5881            .vendor_directory_detection,
5882        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5883        binary_file_behavior: behavior_str,
5884        output_dir: output_dir.unwrap_or("").to_string(),
5885        report_title: run.effective_configuration.reporting.report_title.clone(),
5886        continuation_line_policy: continuation_policy_str,
5887        blank_in_block_comment_policy: blank_policy_str,
5888        count_compiler_directives: run
5889            .effective_configuration
5890            .analysis
5891            .count_compiler_directives,
5892        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5893        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5894        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5895        style_lang_scope: run
5896            .effective_configuration
5897            .analysis
5898            .style_lang_scope
5899            .clone(),
5900        coverage_file: run
5901            .effective_configuration
5902            .analysis
5903            .coverage_file
5904            .as_ref()
5905            .map(|p| p.display().to_string())
5906            .unwrap_or_default(),
5907        cocomo_mode: cocomo_mode.to_string(),
5908        complexity_alert,
5909        exclude_duplicates,
5910        activity_window: run
5911            .effective_configuration
5912            .analysis
5913            .activity_window_days
5914            .unwrap_or(0),
5915    };
5916    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5917        let _ = std::fs::write(cfg_path, json);
5918    }
5919}
5920
5921#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5922fn run_analysis_blocking(
5923    mut config: AppConfig,
5924    git_repo: Option<String>,
5925    git_ref: Option<String>,
5926    clones_dir: PathBuf,
5927    cancel: Arc<std::sync::atomic::AtomicBool>,
5928    progress: Option<sloc_core::ProgressCounters>,
5929) -> Result<sloc_core::AnalysisRun> {
5930    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5931        let dest = git_clone_dest(&repo, &clones_dir);
5932        sloc_git::clone_or_fetch(&repo, &dest)?;
5933        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5934        sloc_git::create_worktree(&dest, &refname, &wt)?;
5935        config.discovery.root_paths = vec![wt.clone()];
5936        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5937        let _ = sloc_git::destroy_worktree(&dest, &wt);
5938        let mut run = run?;
5939        if run.git_branch.is_none() {
5940            run.git_branch = Some(refname);
5941        }
5942        return Ok(run);
5943    }
5944    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5945}
5946
5947fn derive_project_label(
5948    git_repo: Option<&str>,
5949    git_ref: Option<&str>,
5950    fallback_path: &str,
5951) -> String {
5952    match (
5953        git_repo.filter(|s| !s.is_empty()),
5954        git_ref.filter(|s| !s.is_empty()),
5955    ) {
5956        (Some(repo), Some(refname)) => {
5957            let repo_name = repo
5958                .trim_end_matches('/')
5959                .trim_end_matches(".git")
5960                .rsplit('/')
5961                .next()
5962                .unwrap_or("repo");
5963            sanitize_project_label(&format!("{repo_name}_{refname}"))
5964        }
5965        _ => sanitize_project_label(fallback_path),
5966    }
5967}
5968
5969fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5970    let commit = commit_short.unwrap_or("").trim();
5971    if commit.is_empty() {
5972        project_label.to_string()
5973    } else {
5974        format!("{project_label}_{commit}")
5975    }
5976}
5977
5978// ── Async scan status + result handlers ──────────────────────────────────────
5979
5980#[derive(Serialize)]
5981#[serde(tag = "state", rename_all = "snake_case")]
5982enum AsyncRunStatusResponse {
5983    Running {
5984        elapsed_secs: u64,
5985        phase: String,
5986        files_done: u64,
5987        files_total: u64,
5988    },
5989    Complete {
5990        run_id: String,
5991    },
5992    Failed {
5993        message: String,
5994    },
5995    Cancelled,
5996}
5997
5998async fn async_run_status_handler(
5999    State(state): State<AppState>,
6000    AxumPath(wait_id): AxumPath<String>,
6001) -> Response {
6002    // wait_id comes from our own UUID generator; reject any structurally malformed value.
6003    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6004        return error::bad_request("invalid wait_id");
6005    }
6006    let run_state = {
6007        let runs = state.async_runs.lock().await;
6008        runs.get(&wait_id).cloned()
6009    };
6010    match run_state {
6011        None => error::not_found("run not found"),
6012        Some(AsyncRunState::Running {
6013            started_at,
6014            phase,
6015            files_done,
6016            files_total,
6017            ..
6018        }) => {
6019            // Treat runs older than 2 h as timed out (analysis should finish well under that).
6020            if started_at.elapsed() > std::time::Duration::from_hours(2) {
6021                let mut runs = state.async_runs.lock().await;
6022                runs.insert(
6023                    wait_id,
6024                    AsyncRunState::Failed {
6025                        message: "Analysis timed out after 2 hours.".to_string(),
6026                    },
6027                );
6028                drop(runs);
6029                return Json(AsyncRunStatusResponse::Failed {
6030                    message: "Analysis timed out after 2 hours.".to_string(),
6031                })
6032                .into_response();
6033            }
6034            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
6035            Json(AsyncRunStatusResponse::Running {
6036                elapsed_secs: started_at.elapsed().as_secs(),
6037                phase: phase_str,
6038                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
6039                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
6040            })
6041            .into_response()
6042        }
6043        Some(AsyncRunState::Complete { run_id }) => {
6044            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
6045        }
6046        Some(AsyncRunState::Failed { message }) => {
6047            Json(AsyncRunStatusResponse::Failed { message }).into_response()
6048        }
6049        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
6050    }
6051}
6052
6053async fn cancel_run_handler(
6054    State(state): State<AppState>,
6055    AxumPath(wait_id): AxumPath<String>,
6056) -> Response {
6057    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6058        return error::bad_request("invalid wait_id");
6059    }
6060    let mut runs = state.async_runs.lock().await;
6061    let resp = match runs.get(&wait_id) {
6062        Some(AsyncRunState::Running { cancel_token, .. }) => {
6063            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
6064            runs.insert(wait_id, AsyncRunState::Cancelled);
6065            StatusCode::OK.into_response()
6066        }
6067        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
6068        _ => error::not_found("run not found"),
6069    };
6070    drop(runs);
6071    resp
6072}
6073
6074async fn async_run_result_handler(
6075    State(state): State<AppState>,
6076    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6077    AxumPath(run_id): AxumPath<String>,
6078) -> Response {
6079    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
6080        return StatusCode::BAD_REQUEST.into_response();
6081    }
6082
6083    let artifacts = {
6084        let map = state.artifacts.lock().await;
6085        map.get(&run_id).cloned()
6086    };
6087    let artifacts = if let Some(a) = artifacts {
6088        a
6089    } else {
6090        let reg = state.registry.lock().await;
6091        if let Some(entry) = reg.find_by_run_id(&run_id) {
6092            recover_artifacts_from_registry(entry)
6093        } else {
6094            let html = ErrorTemplate {
6095                message: format!(
6096                    "Report not found. Run ID {} is not in the scan history.",
6097                    &run_id[..run_id.len().min(8)]
6098                ),
6099                last_report_url: Some("/view-reports".to_string()),
6100                last_report_label: Some("View Reports".to_string()),
6101                run_id: Some(run_id.clone()),
6102                error_code: Some(404),
6103                csp_nonce: csp_nonce.clone(),
6104                version: env!("CARGO_PKG_VERSION"),
6105            }
6106            .render()
6107            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6108            return (StatusCode::NOT_FOUND, Html(html)).into_response();
6109        }
6110    };
6111
6112    let json_path = if let Some(p) = &artifacts.json_path {
6113        p.clone()
6114    } else {
6115        let html = ErrorTemplate {
6116            message: "JSON result was not saved for this run.".to_string(),
6117            last_report_url: Some("/view-reports".to_string()),
6118            last_report_label: Some("View Reports".to_string()),
6119            run_id: Some(run_id.clone()),
6120            error_code: Some(404),
6121            csp_nonce: csp_nonce.clone(),
6122            version: env!("CARGO_PKG_VERSION"),
6123        }
6124        .render()
6125        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
6126        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6127    };
6128
6129    let Ok(run) = read_json(&json_path) else {
6130        let folder_hint = output_folder_hint(&json_path);
6131        let redirect_url = format!("/runs/result/{run_id}");
6132        return missing_scan_relocate_response(
6133            &format!(
6134                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
6135                 deleted. Browse to the folder containing your scan output to reconnect it.",
6136                json_path.display()
6137            ),
6138            &run_id,
6139            &folder_hint,
6140            &redirect_url,
6141            state.server_mode,
6142            &csp_nonce,
6143        );
6144    };
6145
6146    let confluence_configured = {
6147        let store = state.confluence.lock().await;
6148        store.is_configured()
6149    };
6150
6151    render_result_page(
6152        &run,
6153        &artifacts,
6154        &run_id,
6155        &csp_nonce,
6156        confluence_configured,
6157        state.server_mode,
6158    )
6159}
6160
6161/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
6162fn json_escape(s: &str) -> String {
6163    s.replace('\\', "\\\\").replace('"', "\\\"")
6164}
6165
6166/// Per-language line/symbol totals summed across every language in a run.
6167struct LangTotals {
6168    physical_lines: u64,
6169    code_lines: u64,
6170    comment_lines: u64,
6171    blank_lines: u64,
6172    mixed_lines: u64,
6173    functions: u64,
6174    classes: u64,
6175    variables: u64,
6176    imports: u64,
6177}
6178
6179fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
6180    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
6181        run.totals_by_language.iter().map(f).sum()
6182    };
6183    LangTotals {
6184        physical_lines: s(|r| r.total_physical_lines),
6185        code_lines: s(|r| r.code_lines),
6186        comment_lines: s(|r| r.comment_lines),
6187        blank_lines: s(|r| r.blank_lines),
6188        mixed_lines: s(|r| r.mixed_lines_separate),
6189        functions: s(|r| r.functions),
6190        classes: s(|r| r.classes),
6191        variables: s(|r| r.variables),
6192        imports: s(|r| r.imports),
6193    }
6194}
6195
6196/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
6197struct DeltaFields {
6198    prev_fa_str: String,
6199    prev_fs_str: String,
6200    prev_pl_str: String,
6201    prev_cl_str: String,
6202    prev_cml_str: String,
6203    prev_bl_str: String,
6204    delta_fa_str: String,
6205    delta_fa_class: String,
6206    delta_fs_str: String,
6207    delta_fs_class: String,
6208    delta_pl_str: String,
6209    delta_pl_class: String,
6210    delta_cl_str: String,
6211    delta_cl_class: String,
6212    delta_cml_str: String,
6213    delta_cml_class: String,
6214    delta_bl_str: String,
6215    delta_bl_class: String,
6216    delta_lines_added: Option<i64>,
6217    delta_lines_removed: Option<i64>,
6218    delta_lines_net_str: String,
6219    delta_lines_net_class: String,
6220}
6221
6222// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
6223// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
6224// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
6225// from those field names and obscure the 1:1 mapping.
6226#[allow(
6227    clippy::similar_names,
6228    reason = "locals mirror template-bound struct fields"
6229)]
6230fn compute_delta_fields(
6231    prev_entry: Option<&RegistryEntry>,
6232    totals: &LangTotals,
6233    files_analyzed: u64,
6234    files_skipped: u64,
6235    scan_delta: Option<&sloc_core::ScanComparison>,
6236) -> DeltaFields {
6237    let prev_sum = prev_entry.map(|e| &e.summary);
6238    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6239
6240    let (delta_fa_str, delta_fa_class) =
6241        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6242    let (delta_fs_str, delta_fs_class) =
6243        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6244    let (delta_pl_str, delta_pl_class) = summary_delta(
6245        totals.physical_lines,
6246        prev_sum.map(|s| s.total_physical_lines),
6247    );
6248    let (delta_cl_str, delta_cl_class) =
6249        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6250    let (delta_cml_str, delta_cml_class) =
6251        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6252    let (delta_bl_str, delta_bl_class) =
6253        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6254
6255    let delta_lines_added = scan_delta.map(sum_added_code_lines);
6256    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6257    let (delta_lines_net_str, delta_lines_net_class) =
6258        match (delta_lines_added, delta_lines_removed) {
6259            (Some(a), Some(r)) => {
6260                let net = a - r;
6261                (fmt_delta(net), delta_class(net).to_string())
6262            }
6263            _ => ("\u{2014}".to_string(), "na".to_string()),
6264        };
6265
6266    DeltaFields {
6267        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6268        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6269        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6270        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6271        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6272        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6273        delta_fa_str,
6274        delta_fa_class: delta_fa_class.to_string(),
6275        delta_fs_str,
6276        delta_fs_class: delta_fs_class.to_string(),
6277        delta_pl_str,
6278        delta_pl_class: delta_pl_class.to_string(),
6279        delta_cl_str,
6280        delta_cl_class: delta_cl_class.to_string(),
6281        delta_cml_str,
6282        delta_cml_class: delta_cml_class.to_string(),
6283        delta_bl_str,
6284        delta_bl_class: delta_bl_class.to_string(),
6285        delta_lines_added,
6286        delta_lines_removed,
6287        delta_lines_net_str,
6288        delta_lines_net_class,
6289    }
6290}
6291
6292/// Count of unchanged code lines in a scan comparison.
6293fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6294    scan_delta
6295        .file_deltas
6296        .iter()
6297        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6298        .map(|f| {
6299            #[allow(clippy::cast_sign_loss)]
6300            let n = f.current_code as u64;
6301            n
6302        })
6303        .sum()
6304}
6305
6306fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6307    run.git_remote_url
6308        .as_deref()
6309        .zip(run.git_commit_long.as_deref())
6310        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6311}
6312
6313fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6314    run.git_remote_url
6315        .as_deref()
6316        .zip(run.git_branch.as_deref())
6317        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6318}
6319
6320fn scan_performed_by(run: &AnalysisRun) -> String {
6321    run.environment.ci_name.clone().unwrap_or_else(|| {
6322        format!(
6323            "{} / {}",
6324            run.environment.initiator_username, run.environment.initiator_hostname
6325        )
6326    })
6327}
6328
6329/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
6330fn build_lang_chart_json(run: &AnalysisRun) -> String {
6331    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6332    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6333    let entries: Vec<String> = langs
6334        .into_iter()
6335        .take(12)
6336        .map(|l| {
6337            let name = json_escape(l.language.display_name());
6338            format!(
6339                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6340                name,
6341                l.code_lines,
6342                l.comment_lines,
6343                l.blank_lines,
6344                l.total_physical_lines,
6345                l.functions,
6346                l.classes,
6347                l.variables,
6348                l.imports,
6349                l.files,
6350            )
6351        })
6352        .collect();
6353    format!("[{}]", entries.join(","))
6354}
6355
6356/// Per-language files-vs-lines points as a JSON array for the scatter chart.
6357fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6358    let entries: Vec<String> = run
6359        .totals_by_language
6360        .iter()
6361        .map(|l| {
6362            let name = json_escape(l.language.display_name());
6363            format!(
6364                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6365                name, l.files, l.code_lines, l.total_physical_lines,
6366            )
6367        })
6368        .collect();
6369    format!("[{}]", entries.join(","))
6370}
6371
6372/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
6373fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6374    let entries: Vec<String> = run
6375        .totals_by_language
6376        .iter()
6377        .filter(|l| {
6378            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6379        })
6380        .map(|l| {
6381            let name = json_escape(l.language.display_name());
6382            format!(
6383                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6384                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6385            )
6386        })
6387        .collect();
6388    format!("[{}]", entries.join(","))
6389}
6390
6391/// Per-submodule line counts as a JSON array for the submodule chart.
6392fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6393    let entries: Vec<String> = run
6394        .submodule_summaries
6395        .iter()
6396        .map(|s| {
6397            let name = json_escape(&s.name);
6398            format!(
6399                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6400                name,
6401                s.code_lines,
6402                s.comment_lines,
6403                s.blank_lines,
6404                s.total_physical_lines,
6405                s.files_analyzed,
6406            )
6407        })
6408        .collect();
6409    format!("[{}]", entries.join(","))
6410}
6411
6412/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
6413#[allow(clippy::cast_precision_loss)]
6414fn cov_pct_str(hit: u64, found: u64) -> String {
6415    if found > 0 {
6416        format!("{:.1}", hit as f64 / found as f64 * 100.0)
6417    } else {
6418        String::new()
6419    }
6420}
6421
6422/// `hit / found` summary string, or empty when nothing was found.
6423fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6424    if found > 0 {
6425        format!("{hit} / {found}")
6426    } else {
6427        String::new()
6428    }
6429}
6430
6431const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6432    use sloc_core::CocomoMode;
6433    match mode {
6434        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6435        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6436        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6437    }
6438}
6439
6440const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6441    use sloc_core::CocomoMode;
6442    match mode {
6443        CocomoMode::Organic => "Organic",
6444        CocomoMode::SemiDetached => "Semi-detached",
6445        CocomoMode::Embedded => "Embedded",
6446    }
6447}
6448
6449const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6450    use sloc_core::CocomoMode;
6451    match mode {
6452        CocomoMode::Organic => {
6453            "Organic: A small team working on a well-understood project in a familiar \
6454             environment with minimal external constraints. Suited for internal tools, \
6455             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6456        }
6457        CocomoMode::SemiDetached => {
6458            "Semi-detached: A mixed team with varying experience tackling a project with \
6459             moderate novelty and some rigid constraints. Typical for compilers, transaction \
6460             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6461        }
6462        CocomoMode::Embedded => {
6463            "Embedded: Tight hardware, software, or operational constraints requiring \
6464             significant innovation and deep integration work. Typical for real-time control \
6465             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6466        }
6467    }
6468}
6469
6470/// COCOMO display strings recomputed for the scan-wizard-selected mode.
6471struct CocomoFields {
6472    has_cocomo: bool,
6473    effort_str: String,
6474    duration_str: String,
6475    staff_str: String,
6476    ksloc_str: String,
6477    mode_label: String,
6478    mode_tooltip: String,
6479}
6480
6481#[allow(clippy::cast_precision_loss)]
6482fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6483    use sloc_core::CocomoMode;
6484    let mode = match mode_str {
6485        "semi_detached" => CocomoMode::SemiDetached,
6486        "embedded" => CocomoMode::Embedded,
6487        _ => CocomoMode::Organic,
6488    };
6489    let (a, b, c, d) = cocomo_coefficients(mode);
6490    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6491    let effort = a * ksloc.powf(b);
6492    let duration = c * effort.powf(d);
6493    let staff = if duration > 0.0 {
6494        effort / duration
6495    } else {
6496        0.0
6497    };
6498    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6499    let mode_label = cocomo_mode_label(mode).to_string();
6500    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6501    if run.summary_totals.code_lines > 0 {
6502        CocomoFields {
6503            has_cocomo: true,
6504            effort_str: round2(effort),
6505            duration_str: round2(duration),
6506            staff_str: round2(staff),
6507            ksloc_str: round2(ksloc),
6508            mode_label,
6509            mode_tooltip,
6510        }
6511    } else {
6512        CocomoFields {
6513            has_cocomo: false,
6514            effort_str: String::new(),
6515            duration_str: String::new(),
6516            staff_str: String::new(),
6517            ksloc_str: String::new(),
6518            mode_label,
6519            mode_tooltip,
6520        }
6521    }
6522}
6523
6524#[allow(clippy::too_many_lines)]
6525#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
6526#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
6527fn render_result_page(
6528    run: &AnalysisRun,
6529    artifacts: &RunArtifacts,
6530    run_id: &str,
6531    csp_nonce: &str,
6532    confluence_configured: bool,
6533    server_mode: bool,
6534) -> Response {
6535    let ctx = &artifacts.result_context;
6536    let prev_entry = &ctx.prev_entry;
6537    let prev_scan_count = ctx.prev_scan_count;
6538    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
6539    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
6540    // field is never blank.
6541    let project_path_owned = if ctx.project_path.is_empty() {
6542        run.input_roots.join(", ")
6543    } else {
6544        ctx.project_path.clone()
6545    };
6546    let project_path = &project_path_owned;
6547
6548    let scan_delta = prev_entry.as_ref().and_then(|prev| {
6549        prev.json_path
6550            .as_ref()
6551            .and_then(|p| read_json(p).ok())
6552            .map(|prev_run| compute_delta(&prev_run, run))
6553    });
6554
6555    let files_analyzed = run.per_file_records.len() as u64;
6556    let files_skipped = run.skipped_file_records.len() as u64;
6557    let totals = sum_lang_totals(run);
6558
6559    let DeltaFields {
6560        prev_fa_str,
6561        prev_fs_str,
6562        prev_pl_str,
6563        prev_cl_str,
6564        prev_cml_str,
6565        prev_bl_str,
6566        delta_fa_str,
6567        delta_fa_class,
6568        delta_fs_str,
6569        delta_fs_class,
6570        delta_pl_str,
6571        delta_pl_class,
6572        delta_cl_str,
6573        delta_cl_class,
6574        delta_cml_str,
6575        delta_cml_class,
6576        delta_bl_str,
6577        delta_bl_class,
6578        delta_lines_added,
6579        delta_lines_removed,
6580        delta_lines_net_str,
6581        delta_lines_net_class,
6582    } = compute_delta_fields(
6583        prev_entry.as_ref(),
6584        &totals,
6585        files_analyzed,
6586        files_skipped,
6587        scan_delta.as_ref(),
6588    );
6589
6590    let run_dir = artifacts.output_dir.clone();
6591    let git_branch = run.git_branch.clone();
6592    let git_commit = run.git_commit_short.clone();
6593    let git_commit_long = run.git_commit_long.clone();
6594    let git_author = run.git_commit_author.clone();
6595    let git_commit_url = git_commit_url_for(run);
6596    let git_branch_url = git_branch_url_for(run);
6597    let scan_performed_by = scan_performed_by(run);
6598    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6599    let os_display = format!(
6600        "{} / {}",
6601        run.environment.operating_system, run.environment.architecture
6602    );
6603    let test_count = run.summary_totals.test_count;
6604
6605    // ── New metrics ──────────────────────────────────────────────────────────
6606    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6607    let lsloc = run.summary_totals.lsloc;
6608    let uloc = run.uloc;
6609    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6610    let duplicate_group_count = run.duplicate_groups.len();
6611
6612    // Re-compute COCOMO with the mode selected in the scan wizard.
6613    let ctx = &artifacts.result_context;
6614    let CocomoFields {
6615        has_cocomo,
6616        effort_str: cocomo_effort_str,
6617        duration_str: cocomo_duration_str,
6618        staff_str: cocomo_staff_str,
6619        ksloc_str: cocomo_ksloc_str,
6620        mode_label: cocomo_mode_label,
6621        mode_tooltip: cocomo_mode_tooltip,
6622    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6623    let complexity_alert = ctx.complexity_alert;
6624
6625    let template = ResultTemplate {
6626        version: env!("CARGO_PKG_VERSION"),
6627        report_title: run.effective_configuration.reporting.report_title.clone(),
6628        project_path: project_path.clone(),
6629        output_dir: display_path(&artifacts.output_dir),
6630        run_id: run_id.to_owned(),
6631        run_id_short: run_id
6632            .split('-')
6633            .next_back()
6634            .unwrap_or(run_id)
6635            .chars()
6636            .take(7)
6637            .collect(),
6638        files_analyzed,
6639        files_skipped,
6640        physical_lines: totals.physical_lines,
6641        code_lines: totals.code_lines,
6642        comment_lines: totals.comment_lines,
6643        blank_lines: totals.blank_lines,
6644        mixed_lines: totals.mixed_lines,
6645        functions: totals.functions,
6646        classes: totals.classes,
6647        variables: totals.variables,
6648        imports: totals.imports,
6649        html_url: artifacts
6650            .html_path
6651            .as_ref()
6652            .map(|_| format!("/runs/html/{run_id}")),
6653        pdf_url: artifacts
6654            .pdf_path
6655            .as_ref()
6656            .map(|_| format!("/runs/pdf/{run_id}")),
6657        json_url: artifacts
6658            .json_path
6659            .as_ref()
6660            .map(|_| format!("/runs/json/{run_id}")),
6661        html_download_url: artifacts
6662            .html_path
6663            .as_ref()
6664            .map(|_| format!("/runs/html/{run_id}?download=1")),
6665        pdf_download_url: artifacts
6666            .pdf_path
6667            .as_ref()
6668            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6669        json_download_url: artifacts
6670            .json_path
6671            .as_ref()
6672            .map(|_| format!("/runs/json/{run_id}?download=1")),
6673        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6674        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6675        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6676        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6677        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6678        prev_fa_str,
6679        prev_fs_str,
6680        prev_pl_str,
6681        prev_cl_str,
6682        prev_cml_str,
6683        prev_bl_str,
6684        delta_fa_str,
6685        delta_fa_class,
6686        delta_fs_str,
6687        delta_fs_class,
6688        delta_pl_str,
6689        delta_pl_class,
6690        delta_cl_str,
6691        delta_cl_class,
6692        delta_cml_str,
6693        delta_cml_class,
6694        delta_bl_str,
6695        delta_bl_class,
6696        delta_lines_added,
6697        delta_lines_removed,
6698        delta_lines_net_str,
6699        delta_lines_net_class,
6700        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6701        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6702        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6703        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6704        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6705        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6706        git_branch,
6707        git_branch_url,
6708        git_commit,
6709        git_commit_long,
6710        git_author,
6711        git_commit_url,
6712        scan_performed_by,
6713        scan_time_display,
6714        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6715        os_display,
6716        test_count,
6717        test_assertion_count: run.summary_totals.test_assertion_count,
6718        current_scan_number: prev_scan_count + 1,
6719        prev_scan_count,
6720        submodule_rows: run
6721            .submodule_summaries
6722            .iter()
6723            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6724            .collect(),
6725        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6726        scan_config_url: format!("/runs/scan-config/{run_id}"),
6727        lang_chart_json: build_lang_chart_json(run),
6728        scatter_chart_json: build_scatter_chart_json(run),
6729        semantic_chart_json: build_semantic_chart_json(run),
6730        submodule_chart_json: build_submodule_chart_json(run),
6731        has_submodule_data: !run.submodule_summaries.is_empty(),
6732        has_semantic_data: run
6733            .totals_by_language
6734            .iter()
6735            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6736        csp_nonce: csp_nonce.to_owned(),
6737        confluence_configured,
6738        server_mode,
6739        report_header_footer: run
6740            .effective_configuration
6741            .reporting
6742            .report_header_footer
6743            .clone(),
6744        is_offline: false,
6745        cyclomatic_complexity,
6746        lsloc,
6747        uloc,
6748        dryness_pct_str,
6749        duplicate_group_count,
6750        has_cocomo,
6751        cocomo_effort_str,
6752        cocomo_duration_str,
6753        cocomo_staff_str,
6754        cocomo_ksloc_str,
6755        cocomo_mode_label,
6756        cocomo_mode_tooltip,
6757        complexity_alert,
6758        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6759        cov_line_pct: cov_pct_str(
6760            run.summary_totals.coverage_lines_hit,
6761            run.summary_totals.coverage_lines_found,
6762        ),
6763        cov_fn_pct: cov_pct_str(
6764            run.summary_totals.coverage_functions_hit,
6765            run.summary_totals.coverage_functions_found,
6766        ),
6767        cov_branch_pct: cov_pct_str(
6768            run.summary_totals.coverage_branches_hit,
6769            run.summary_totals.coverage_branches_found,
6770        ),
6771        cov_lines_summary: cov_lines_summary_str(
6772            run.summary_totals.coverage_lines_hit,
6773            run.summary_totals.coverage_lines_found,
6774        ),
6775    };
6776
6777    Html(
6778        template
6779            .render()
6780            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6781    )
6782    .into_response()
6783}
6784
6785fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6786    let slug: String = report_title
6787        .chars()
6788        .map(|c| {
6789            if c.is_alphanumeric() || c == '-' {
6790                c.to_ascii_lowercase()
6791            } else {
6792                '_'
6793            }
6794        })
6795        .collect::<String>()
6796        .split('_')
6797        .filter(|s| !s.is_empty())
6798        .collect::<Vec<_>>()
6799        .join("_");
6800
6801    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6802
6803    if slug.is_empty() {
6804        format!("report_{short_id}.pdf")
6805    } else {
6806        format!("{slug}_{short_id}.pdf")
6807    }
6808}
6809
6810#[derive(Serialize)]
6811struct PdfStatusResponse {
6812    ready: bool,
6813}
6814
6815/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6816/// Clients poll this to update the button state without page reloads.
6817async fn pdf_status_handler(
6818    State(state): State<AppState>,
6819    AxumPath(run_id): AxumPath<String>,
6820) -> Response {
6821    let pdf_path = {
6822        let registry = state.artifacts.lock().await;
6823        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6824    };
6825    let pdf_path = if pdf_path.is_some() {
6826        pdf_path
6827    } else {
6828        let reg = state.registry.lock().await;
6829        reg.find_by_run_id(&run_id)
6830            .map(recover_artifacts_from_registry)
6831            .and_then(|a| a.pdf_path)
6832    };
6833    let ready = pdf_path.is_some_and(|p| p.exists());
6834    Json(PdfStatusResponse { ready }).into_response()
6835}
6836
6837/// GET /`api/runs/:run_id/bundle`
6838///
6839/// Streams a gzip-compressed tar archive containing every artifact in the run's
6840/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6841/// is built in memory so it never touches a temp file.
6842async fn download_bundle_handler(
6843    State(state): State<AppState>,
6844    AxumPath(run_id): AxumPath<String>,
6845) -> Response {
6846    // Resolve output directory from in-memory cache or persisted registry.
6847    let output_dir = {
6848        let cache = state.artifacts.lock().await;
6849        cache.get(&run_id).map(|a| a.output_dir.clone())
6850    };
6851    let output_dir = if let Some(d) = output_dir {
6852        d
6853    } else {
6854        let reg = state.registry.lock().await;
6855        match reg.find_by_run_id(&run_id) {
6856            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6857            None => {
6858                return (
6859                    StatusCode::NOT_FOUND,
6860                    Json(serde_json::json!({"error": "Run not found"})),
6861                )
6862                    .into_response();
6863            }
6864        }
6865    };
6866
6867    if !output_dir.exists() {
6868        return (
6869            StatusCode::NOT_FOUND,
6870            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6871        )
6872            .into_response();
6873    }
6874
6875    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6876    let run_id_clone = run_id.clone();
6877    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6878        use flate2::{Compression, write::GzEncoder};
6879        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6880        {
6881            let mut tar = tar::Builder::new(&mut enc);
6882            tar.follow_symlinks(false);
6883            // Append every regular file in the output directory, skipping
6884            // sub-directories (the output dir is always flat).
6885            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6886                for entry in entries.filter_map(Result::ok) {
6887                    let p = entry.path();
6888                    if p.is_file() {
6889                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6890                        let archive_path = format!("{run_id_clone}/{name}");
6891                        tar.append_path_with_name(&p, &archive_path)?;
6892                    }
6893                }
6894            }
6895            tar.finish()?;
6896        }
6897        Ok(enc.finish()?)
6898    })
6899    .await;
6900
6901    match archive_result {
6902        Ok(Ok(bytes)) => {
6903            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6904            axum::response::Response::builder()
6905                .status(StatusCode::OK)
6906                .header("Content-Type", "application/gzip")
6907                .header(
6908                    "Content-Disposition",
6909                    format!("attachment; filename=\"{filename}\""),
6910                )
6911                .header("Content-Length", bytes.len().to_string())
6912                .body(axum::body::Body::from(bytes))
6913                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6914        }
6915        Ok(Err(e)) => (
6916            StatusCode::INTERNAL_SERVER_ERROR,
6917            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6918        )
6919            .into_response(),
6920        Err(e) => (
6921            StatusCode::INTERNAL_SERVER_ERROR,
6922            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6923        )
6924            .into_response(),
6925    }
6926}
6927
6928/// DELETE /`api/runs/:run_id`
6929///
6930/// Removes all on-disk artifacts for the run and purges the run from the
6931/// in-memory cache and the persisted registry. Returns 204 on success.
6932async fn delete_run_handler(
6933    State(state): State<AppState>,
6934    AxumPath(run_id): AxumPath<String>,
6935) -> Response {
6936    // Resolve output directory.
6937    let output_dir = {
6938        let mut cache = state.artifacts.lock().await;
6939        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6940        cache.remove(&run_id);
6941        dir
6942    };
6943    let output_dir = if let Some(d) = output_dir {
6944        d
6945    } else {
6946        let reg = state.registry.lock().await;
6947        reg.find_by_run_id(&run_id)
6948            .map(|e| recover_artifacts_from_registry(e).output_dir)
6949            .unwrap_or_default()
6950    };
6951
6952    // Remove from persisted registry.
6953    {
6954        let mut reg = state.registry.lock().await;
6955        reg.entries.retain(|e| e.run_id != run_id);
6956        let _ = reg.save(&state.registry_path);
6957    }
6958
6959    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6960    // a prior delete may have already removed the directory.
6961    if output_dir.exists() {
6962        match tokio::fs::remove_dir_all(&output_dir).await {
6963            Ok(()) => {}
6964            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6965            Err(e) => {
6966                return (
6967                    StatusCode::INTERNAL_SERVER_ERROR,
6968                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6969                )
6970                    .into_response();
6971            }
6972        }
6973    }
6974
6975    StatusCode::NO_CONTENT.into_response()
6976}
6977
6978/// POST /api/runs/cleanup
6979///
6980/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6981/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6982async fn cleanup_runs_handler(
6983    State(state): State<AppState>,
6984    Json(body): Json<serde_json::Value>,
6985) -> Response {
6986    let days = body
6987        .get("older_than_days")
6988        .and_then(serde_json::Value::as_u64)
6989        .unwrap_or(30)
6990        .max(1);
6991
6992    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6993
6994    // Collect expired entries from the registry.
6995    let expired: Vec<(String, PathBuf)> = {
6996        let reg = state.registry.lock().await;
6997        reg.entries
6998            .iter()
6999            .filter(|e| e.timestamp_utc < cutoff)
7000            .map(|e| {
7001                let arts = recover_artifacts_from_registry(e);
7002                (e.run_id.clone(), arts.output_dir)
7003            })
7004            .collect()
7005    };
7006
7007    let mut deleted = 0usize;
7008    for (run_id, output_dir) in &expired {
7009        // Remove from in-memory cache.
7010        state.artifacts.lock().await.remove(run_id);
7011        // Delete on-disk artifacts (non-fatal if already gone).
7012        if output_dir.exists()
7013            && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
7014        {
7015            eprintln!(
7016                "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
7017                output_dir.display()
7018            );
7019            continue;
7020        }
7021        deleted += 1;
7022    }
7023
7024    // Purge expired run IDs from the registry in one pass.
7025    let expired_ids: std::collections::HashSet<&str> =
7026        expired.iter().map(|(id, _)| id.as_str()).collect();
7027    {
7028        let mut reg = state.registry.lock().await;
7029        reg.entries
7030            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
7031        let _ = reg.save(&state.registry_path);
7032    }
7033
7034    Json(serde_json::json!({ "deleted": deleted })).into_response()
7035}
7036
7037/// Spawns the background auto-cleanup task. Returns a handle so the caller can
7038/// abort it when the policy is updated or disabled.
7039fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
7040    tokio::spawn(async move {
7041        loop {
7042            let interval_secs = {
7043                let store = state.cleanup_policy.lock().await;
7044                match &store.policy {
7045                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
7046                    _ => break,
7047                }
7048            };
7049            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
7050            let n = run_auto_cleanup(&state).await;
7051            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
7052        }
7053    })
7054}
7055
7056fn collect_runs_to_delete(
7057    reg: &ScanRegistry,
7058    max_age_days: Option<u32>,
7059    max_run_count: Option<u32>,
7060) -> std::collections::HashSet<String> {
7061    let mut to_delete = std::collections::HashSet::new();
7062    if let Some(days) = max_age_days {
7063        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
7064        for e in &reg.entries {
7065            if e.timestamp_utc < cutoff {
7066                to_delete.insert(e.run_id.clone());
7067            }
7068        }
7069    }
7070    if let Some(max_count) = max_run_count {
7071        // entries are sorted newest-first; skip the ones we keep
7072        for e in reg.entries.iter().skip(max_count as usize) {
7073            to_delete.insert(e.run_id.clone());
7074        }
7075    }
7076    to_delete
7077}
7078
7079async fn delete_run_artifacts(state: &AppState, run_id: &str) {
7080    let output_dir = {
7081        let mut cache = state.artifacts.lock().await;
7082        let d = cache.get(run_id).map(|a| a.output_dir.clone());
7083        cache.remove(run_id);
7084        d
7085    };
7086    let output_dir = if let Some(d) = output_dir {
7087        d
7088    } else {
7089        let reg = state.registry.lock().await;
7090        reg.find_by_run_id(run_id)
7091            .map(|e| recover_artifacts_from_registry(e).output_dir)
7092            .unwrap_or_default()
7093    };
7094    if output_dir.exists() {
7095        let _ = tokio::fs::remove_dir_all(&output_dir).await;
7096    }
7097}
7098
7099/// Core cleanup logic shared by the background task and the "Run Now" handler.
7100/// Applies both the age limit and the count limit, then updates `last_run_at`.
7101/// Returns the number of runs deleted.
7102async fn run_auto_cleanup(state: &AppState) -> u32 {
7103    let (max_age_days, max_run_count) = {
7104        let store = state.cleanup_policy.lock().await;
7105        match &store.policy {
7106            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
7107            _ => return 0,
7108        }
7109    };
7110
7111    let to_delete = {
7112        let reg = state.registry.lock().await;
7113        collect_runs_to_delete(&reg, max_age_days, max_run_count)
7114    };
7115
7116    for run_id in &to_delete {
7117        delete_run_artifacts(state, run_id).await;
7118    }
7119
7120    // Purge from registry.
7121    if !to_delete.is_empty() {
7122        let mut reg = state.registry.lock().await;
7123        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
7124        let _ = reg.save(&state.registry_path);
7125    }
7126
7127    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
7128    {
7129        let mut store = state.cleanup_policy.lock().await;
7130        store.last_run_at = Some(chrono::Utc::now());
7131        store.last_run_deleted = Some(deleted);
7132        let _ = store.save(&state.cleanup_policy_path);
7133    }
7134    deleted
7135}
7136
7137// ── Auto-cleanup policy API ───────────────────────────────────────────────────
7138
7139/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
7140async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
7141    let store = state.cleanup_policy.lock().await;
7142    Json(serde_json::json!({
7143        "policy": store.policy,
7144        "last_run_at": store.last_run_at,
7145        "last_run_deleted": store.last_run_deleted,
7146    }))
7147    .into_response()
7148}
7149
7150/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
7151async fn api_save_cleanup_policy(
7152    State(state): State<AppState>,
7153    Json(body): Json<CleanupPolicy>,
7154) -> Response {
7155    // Abort any running task so the new interval takes effect immediately.
7156    {
7157        let mut handle = state.cleanup_task_handle.lock().await;
7158        if let Some(h) = handle.take() {
7159            h.abort();
7160        }
7161    }
7162    {
7163        let mut store = state.cleanup_policy.lock().await;
7164        store.policy = Some(body.clone());
7165        if let Err(e) = store.save(&state.cleanup_policy_path) {
7166            return (
7167                StatusCode::INTERNAL_SERVER_ERROR,
7168                Json(serde_json::json!({"error": e.to_string()})),
7169            )
7170                .into_response();
7171        }
7172    }
7173    if body.enabled {
7174        let handle = spawn_cleanup_policy_task(state.clone());
7175        *state.cleanup_task_handle.lock().await = Some(handle);
7176    }
7177    StatusCode::NO_CONTENT.into_response()
7178}
7179
7180/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
7181async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
7182    let deleted = run_auto_cleanup(&state).await;
7183    Json(serde_json::json!({ "deleted": deleted })).into_response()
7184}
7185
7186/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
7187async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
7188    {
7189        let mut handle = state.cleanup_task_handle.lock().await;
7190        if let Some(h) = handle.take() {
7191            h.abort();
7192        }
7193    }
7194    {
7195        let mut store = state.cleanup_policy.lock().await;
7196        store.policy = None;
7197        let _ = store.save(&state.cleanup_policy_path);
7198    }
7199    StatusCode::NO_CONTENT.into_response()
7200}
7201
7202/// Serve the HTML artifact for a run — view or download.
7203/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
7204/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
7205/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
7206/// Only called for browser views; downloads keep the self-contained inline version.
7207fn swap_inline_chart_js_for_static(html: String) -> String {
7208    let Some(head_end) = html.find("</head>") else {
7209        return html;
7210    };
7211    let Some(script_start) = html[..head_end].rfind("<script") else {
7212        return html;
7213    };
7214    let Some(close_offset) = html[script_start..].find("</script>") else {
7215        return html;
7216    };
7217    let block_end = script_start + close_offset + "</script>".len();
7218    format!(
7219        "{}<script src=\"/static/chart-report.js\"></script>{}",
7220        &html[..script_start],
7221        &html[block_end..]
7222    )
7223}
7224
7225/// current-request Content-Security-Policy nonce check.
7226fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
7227    // Find the first nonce value that was baked in at render time.
7228    let Some(start) = html.find("nonce=\"") else {
7229        // Reports generated before nonce support was added have bare <style> and <script>
7230        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
7231        // the inline blocks — without it the browser blocks all CSS and JS.
7232        return html
7233            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7234            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7235    };
7236    let value_start = start + 7; // len(r#"nonce=""#) == 7
7237    let Some(end_offset) = html[value_start..].find('"') else {
7238        return html.to_owned();
7239    };
7240    let old_nonce = &html[value_start..value_start + end_offset];
7241    html.replace(
7242        &format!("nonce=\"{old_nonce}\""),
7243        &format!("nonce=\"{new_nonce}\""),
7244    )
7245}
7246
7247fn serve_html_artifact(
7248    path: &Path,
7249    wants_download: bool,
7250    csp_nonce: &str,
7251    run_id: &str,
7252    server_mode: bool,
7253) -> Response {
7254    match fs::read_to_string(path) {
7255        Ok(raw) => {
7256            // Patch the saved nonce so inline styles/scripts pass CSP.
7257            let content = patch_html_nonce(&raw, csp_nonce);
7258            if wants_download {
7259                // Keep the self-contained inline version for downloads (opened as file://).
7260                (
7261                    [
7262                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7263                        (
7264                            header::CONTENT_DISPOSITION,
7265                            "attachment; filename=report.html",
7266                        ),
7267                    ],
7268                    content,
7269                )
7270                    .into_response()
7271            } else {
7272                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
7273                // browser caches it after the first view; the HTML response also shrinks.
7274                Html(swap_inline_chart_js_for_static(content)).into_response()
7275            }
7276        }
7277        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7278            let filename = path.file_name().map_or_else(
7279                || "report.html".to_string(),
7280                |n| n.to_string_lossy().into_owned(),
7281            );
7282            let html = LocateFileTemplate {
7283                run_id: run_id.to_owned(),
7284                artifact_type: "html".to_string(),
7285                expected_filename: filename,
7286                server_mode,
7287                csp_nonce: csp_nonce.to_owned(),
7288                version: env!("CARGO_PKG_VERSION"),
7289            }
7290            .render()
7291            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7292            (StatusCode::NOT_FOUND, Html(html)).into_response()
7293        }
7294        Err(err) => {
7295            let filename = path.file_name().map_or_else(
7296                || "report.html".to_string(),
7297                |n| n.to_string_lossy().into_owned(),
7298            );
7299            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7300            let html = ErrorTemplate {
7301                message: msg,
7302                last_report_url: Some("/view-reports".to_string()),
7303                last_report_label: Some("View Reports".to_string()),
7304                run_id: None,
7305                error_code: Some(404),
7306                csp_nonce: csp_nonce.to_owned(),
7307                version: env!("CARGO_PKG_VERSION"),
7308            }
7309            .render()
7310            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7311            (StatusCode::NOT_FOUND, Html(html)).into_response()
7312        }
7313    }
7314}
7315
7316/// Serve the PDF artifact for a run — inline or download.
7317fn serve_pdf_artifact(
7318    path: &Path,
7319    report_title: &str,
7320    run_id: &str,
7321    wants_download: bool,
7322    csp_nonce: &str,
7323) -> Response {
7324    match fs::read(path) {
7325        Ok(bytes) => {
7326            let filename = build_pdf_filename(report_title, run_id);
7327            let disposition = if wants_download {
7328                format!("attachment; filename=\"{filename}\"")
7329            } else {
7330                format!("inline; filename=\"{filename}\"")
7331            };
7332            (
7333                [
7334                    (header::CONTENT_TYPE, "application/pdf".to_string()),
7335                    (header::CONTENT_DISPOSITION, disposition),
7336                ],
7337                bytes,
7338            )
7339                .into_response()
7340        }
7341        Err(err) => {
7342            let filename = path.file_name().map_or_else(
7343                || "report.pdf".to_string(),
7344                |n| n.to_string_lossy().into_owned(),
7345            );
7346            let msg = format!(
7347                "PDF report '{filename}' could not be read.\n\n\
7348                 Error: {err}\n\n\
7349                 If you moved or renamed the output folder, the stored path is now stale. \
7350                 Use 'Open PDF folder' from the results page to browse the output directory."
7351            );
7352            let html = ErrorTemplate {
7353                message: msg,
7354                last_report_url: Some("/view-reports".to_string()),
7355                last_report_label: Some("View Reports".to_string()),
7356                run_id: Some(run_id.to_owned()),
7357                error_code: Some(404),
7358                csp_nonce: csp_nonce.to_owned(),
7359                version: env!("CARGO_PKG_VERSION"),
7360            }
7361            .render()
7362            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7363            (StatusCode::NOT_FOUND, Html(html)).into_response()
7364        }
7365    }
7366}
7367
7368/// Serve the JSON artifact for a run — view or download.
7369fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7370    match fs::read(path) {
7371        Ok(bytes) => {
7372            if wants_download {
7373                (
7374                    [
7375                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7376                        (
7377                            header::CONTENT_DISPOSITION,
7378                            "attachment; filename=result.json",
7379                        ),
7380                    ],
7381                    bytes,
7382                )
7383                    .into_response()
7384            } else {
7385                (
7386                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7387                    bytes,
7388                )
7389                    .into_response()
7390            }
7391        }
7392        Err(err) => {
7393            let filename = path.file_name().map_or_else(
7394                || "result.json".to_string(),
7395                |n| n.to_string_lossy().into_owned(),
7396            );
7397            let msg = format!(
7398                "JSON result '{filename}' could not be read.\n\n\
7399                 Error: {err}\n\n\
7400                 If you moved or renamed the output folder, the stored path is now stale. \
7401                 Use 'Open JSON folder' from the results page to browse the output directory."
7402            );
7403            let html = ErrorTemplate {
7404                message: msg,
7405                last_report_url: Some("/view-reports".to_string()),
7406                last_report_label: Some("View Reports".to_string()),
7407                run_id: None,
7408                error_code: Some(404),
7409                csp_nonce: csp_nonce.to_owned(),
7410                version: env!("CARGO_PKG_VERSION"),
7411            }
7412            .render()
7413            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7414            (StatusCode::NOT_FOUND, Html(html)).into_response()
7415        }
7416    }
7417}
7418
7419/// Recover a `RunArtifacts` from the persisted registry for a run ID.
7420fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7421    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
7422    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
7423    let output_dir = entry
7424        .html_path
7425        .as_ref()
7426        .or(entry.json_path.as_ref())
7427        .or(entry.pdf_path.as_ref())
7428        .or(entry.csv_path.as_ref())
7429        .or(entry.xlsx_path.as_ref())
7430        .and_then(|p| {
7431            let parent = p.parent()?;
7432            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7433            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
7434            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7435                parent.parent().map(PathBuf::from)
7436            } else {
7437                Some(parent.to_path_buf())
7438            }
7439        })
7440        .unwrap_or_default();
7441    // Recover pdf_path: use the persisted one, or look for report.pdf
7442    // adjacent to html/json if only the old entries lack it.
7443    let pdf_path = entry.pdf_path.clone().or_else(|| {
7444        let candidate = output_dir.join("report.pdf");
7445        candidate.exists().then_some(candidate)
7446    });
7447    // csv_path / xlsx_path: persisted paths take precedence; fall back to
7448    // scanning the run directory for files matching the expected patterns so
7449    // that runs created before this feature still surface their artifacts.
7450    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7451        // Check excel/ subfolder (new layout) then root (old layout).
7452        for dir in &[output_dir.join("excel"), output_dir.clone()] {
7453            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7454                entries
7455                    .filter_map(std::result::Result::ok)
7456                    .find(|e| {
7457                        let n = e.file_name();
7458                        let n = n.to_string_lossy();
7459                        n.starts_with("report_") && n.ends_with(ext)
7460                    })
7461                    .map(|e| e.path())
7462            }) {
7463                return Some(p);
7464            }
7465        }
7466        None
7467    };
7468
7469    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7470    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7471    RunArtifacts {
7472        output_dir: output_dir.clone(),
7473        html_path: entry.html_path.clone(),
7474        pdf_path,
7475        json_path: entry.json_path.clone(),
7476        csv_path,
7477        xlsx_path,
7478        scan_config_path: find_scan_config_in_dir(&output_dir),
7479        report_title: entry.project_label.clone(),
7480        result_context: RunResultContext::default(),
7481    }
7482}
7483
7484#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
7485async fn resolve_artifact_set(
7486    state: &AppState,
7487    run_id: &str,
7488    csp_nonce: &str,
7489) -> Result<RunArtifacts, Response> {
7490    let cached = state.artifacts.lock().await.get(run_id).cloned();
7491    if let Some(a) = cached {
7492        return Ok(a);
7493    }
7494    let reg = state.registry.lock().await;
7495    if let Some(entry) = reg.find_by_run_id(run_id) {
7496        return Ok(recover_artifacts_from_registry(entry));
7497    }
7498    drop(reg);
7499    let short_id = &run_id[..run_id.len().min(8)];
7500    let hint = if matches!(
7501        run_id,
7502        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7503    ) {
7504        format!(
7505            " The URL format appears to be reversed \u{2014} \
7506             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7507             Use the View Reports page to navigate to your scan."
7508        )
7509    } else {
7510        " The report may have been deleted or the report directory moved. \
7511         Use View Reports to browse your scan history."
7512            .to_string()
7513    };
7514    let error_html = ErrorTemplate {
7515        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7516        last_report_url: Some("/view-reports".to_string()),
7517        last_report_label: Some("View Reports".to_string()),
7518        run_id: None,
7519        error_code: Some(404),
7520        csp_nonce: csp_nonce.to_owned(),
7521        version: env!("CARGO_PKG_VERSION"),
7522    }
7523    .render()
7524    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7525    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7526}
7527
7528/// Return the path to a run's PDF, queuing background generation when it is missing.
7529///
7530/// Returns `Ok(path)` when the PDF is known (it may still be generating).
7531/// Returns `Err(response)` when there is no JSON source to regenerate from.
7532async fn resolve_or_queue_pdf(
7533    state: &AppState,
7534    pdf_path: Option<PathBuf>,
7535    json_path: Option<PathBuf>,
7536    output_dir: PathBuf,
7537    run_id: &str,
7538    report_title: &str,
7539    csp_nonce: &str,
7540) -> Result<PathBuf, Response> {
7541    if let Some(p) = pdf_path {
7542        return Ok(p);
7543    }
7544    let Some(json_src) = json_path.filter(|p| p.exists()) else {
7545        let msg = "PDF report was not generated for this run. \
7546                   Re-run the analysis with PDF output enabled."
7547            .to_string();
7548        let html = ErrorTemplate {
7549            message: msg,
7550            last_report_url: Some(format!("/runs/html/{run_id}")),
7551            last_report_label: Some("View HTML Report".to_string()),
7552            run_id: Some(run_id.to_string()),
7553            error_code: Some(404),
7554            csp_nonce: csp_nonce.to_string(),
7555            version: env!("CARGO_PKG_VERSION"),
7556        }
7557        .render()
7558        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7559        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7560    };
7561    let pdf_filename = build_pdf_filename(report_title, run_id);
7562    let pdf_dest = output_dir.join(&pdf_filename);
7563    if !pdf_dest.exists() {
7564        // Record the pending path so concurrent requests show the spinner.
7565        {
7566            let mut map = state.artifacts.lock().await;
7567            if let Some(entry) = map.get_mut(run_id) {
7568                entry.pdf_path = Some(pdf_dest.clone());
7569            }
7570        }
7571        {
7572            let mut reg = state.registry.lock().await;
7573            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7574                e.pdf_path = Some(pdf_dest.clone());
7575            }
7576            let _ = reg.save(&state.registry_path);
7577        }
7578        spawn_native_pdf_background(
7579            json_src,
7580            pdf_dest.clone(),
7581            run_id.to_string(),
7582            state.artifacts.clone(),
7583        );
7584    }
7585    Ok(pdf_dest)
7586}
7587
7588/// Self-refreshing "please wait" page shown while the background PDF task is still running.
7589fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7590    let html = format!(
7591        "<!doctype html><html lang=\"en\"><head>\
7592                     <meta charset=utf-8>\
7593                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7594                     <meta http-equiv=\"refresh\" content=\"5\">\
7595                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
7596                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7597                     <style nonce=\"{csp_nonce}\">\
7598                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7599                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7600                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7601                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7602                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7603                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7604                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7605                     background:var(--bg);color:var(--text);}}\
7606                     .top-nav{{position:sticky;top:0;z-index:30;\
7607                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7608                     border-bottom:1px solid rgba(255,255,255,0.12);\
7609                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7610                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7611                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
7612                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7613                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7614                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7615                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7616                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7617                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7618                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7619                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7620                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7621                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7622                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7623                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7624                     justify-content:center;min-height:38px;border-radius:999px;\
7625                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7626                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7627                     .theme-toggle .icon-sun{{display:none;}}\
7628                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7629                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7630                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7631                     display:flex;align-items:center;justify-content:center;\
7632                     min-height:calc(100vh - 56px);}}\
7633                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7634                     .panel{{background:var(--surface);border:1px solid var(--line);\
7635                     border-radius:var(--radius);box-shadow:var(--shadow);\
7636                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7637                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
7638                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
7639                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7640                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7641                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7642                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7643                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7644                     min-height:42px;padding:0 20px;border-radius:14px;\
7645                     border:1px solid var(--line-strong);text-decoration:none;\
7646                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7647                     .back-link:hover{{background:var(--line);}}\
7648                     </style></head>\
7649                     <body>\
7650                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7651                       <a class=\"brand\" href=\"/\">\
7652                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7653                         <div class=\"brand-copy\">\
7654                           <div class=\"brand-title\">OxideSLOC</div>\
7655                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7656                         </div>\
7657                       </a>\
7658                       <div class=\"nav-right\">\
7659                         <a class=\"nav-pill\" href=\"/\">Home</a>\
7660                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7661                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7662                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7663                           <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>\
7664                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7665                           <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>\
7666                         </button>\
7667                       </div>\
7668                     </div></div>\
7669                     <div class=\"page\"><div class=\"panel\">\
7670                       <div class=\"spin-ring\"></div>\
7671                       <h1>Generating PDF\u{2026}</h1>\
7672                       <p>The PDF is being generated from the scan results.<br>\
7673                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7674                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7675                     </div></div>\
7676                     <script nonce=\"{csp_nonce}\">\
7677                     (function(){{\
7678                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7679                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7680                       var t=document.getElementById(\"theme-toggle\");\
7681                       if(t)t.addEventListener(\"click\",function(){{\
7682                         var d=b.classList.toggle(\"dark-theme\");\
7683                         localStorage.setItem(k,d?\"dark\":\"light\");\
7684                       }});\
7685                     }})();\
7686                     </script>\
7687                     </body></html>"
7688    );
7689    Html(html).into_response()
7690}
7691
7692/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7693fn render_error_artifact_html(
7694    message: String,
7695    last_report_url: Option<String>,
7696    last_report_label: Option<String>,
7697    run_id: Option<String>,
7698    error_code: Option<u16>,
7699    csp_nonce: &str,
7700) -> String {
7701    ErrorTemplate {
7702        message,
7703        last_report_url,
7704        last_report_label,
7705        run_id,
7706        error_code,
7707        csp_nonce: csp_nonce.to_owned(),
7708        version: env!("CARGO_PKG_VERSION"),
7709    }
7710    .render()
7711    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7712}
7713
7714/// Read a file and serve it as an attachment download.
7715fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7716    fs::read(path).map_or_else(
7717        |_| StatusCode::NOT_FOUND.into_response(),
7718        |bytes| {
7719            let filename = path.file_name().map_or_else(
7720                || fallback_filename.to_string(),
7721                |n| n.to_string_lossy().into_owned(),
7722            );
7723            (
7724                [
7725                    (header::CONTENT_TYPE, content_type.to_string()),
7726                    (
7727                        header::CONTENT_DISPOSITION,
7728                        format!("attachment; filename=\"{filename}\""),
7729                    ),
7730                ],
7731                bytes,
7732            )
7733                .into_response()
7734        },
7735    )
7736}
7737
7738fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7739    let Some(path) = csv_path else {
7740        let html = render_error_artifact_html(
7741            "CSV report was not generated for this run, or was not recorded in \
7742             the scan registry."
7743                .to_string(),
7744            Some(format!("/runs/html/{run_id}")),
7745            Some("View HTML Report".to_string()),
7746            Some(run_id.to_string()),
7747            Some(404),
7748            csp_nonce,
7749        );
7750        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7751    };
7752    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7753}
7754
7755fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7756    let Some(path) = xlsx_path else {
7757        let html = render_error_artifact_html(
7758            "Excel report was not generated for this run, or was not recorded in \
7759             the scan registry."
7760                .to_string(),
7761            Some(format!("/runs/html/{run_id}")),
7762            Some("View HTML Report".to_string()),
7763            Some(run_id.to_string()),
7764            Some(404),
7765            csp_nonce,
7766        );
7767        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7768    };
7769    serve_binary_download(
7770        &path,
7771        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7772        "report.xlsx",
7773    )
7774}
7775
7776fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7777    let path = artifact_set
7778        .scan_config_path
7779        .as_deref()
7780        .map(std::path::Path::to_path_buf)
7781        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7782        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7783    fs::read(&path).map_or_else(
7784        |_| StatusCode::NOT_FOUND.into_response(),
7785        |bytes| {
7786            (
7787                [
7788                    (
7789                        header::CONTENT_TYPE,
7790                        "application/json; charset=utf-8".to_string(),
7791                    ),
7792                    (
7793                        header::CONTENT_DISPOSITION,
7794                        "attachment; filename=\"scan-config.json\"".to_string(),
7795                    ),
7796                ],
7797                bytes,
7798            )
7799                .into_response()
7800        },
7801    )
7802}
7803
7804/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7805/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7806/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7807/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7808async fn serve_submodule_pdf_arm(
7809    artifact: &str,
7810    artifact_set: RunArtifacts,
7811    wants_download: bool,
7812    run_id: &str,
7813    csp_nonce: &str,
7814) -> Response {
7815    // "sub_benchmark_pdf" → base = "sub_benchmark"
7816    let base = artifact.trim_end_matches("_pdf");
7817    let sub_dir = artifact_set.output_dir.join("submodules");
7818    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7819
7820    if !pdf_path.exists() {
7821        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7822        let derived_safe = base.trim_start_matches("sub_");
7823        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7824            let parent_run = read_json(jp).ok()?;
7825            let sub = parent_run
7826                .submodule_summaries
7827                .iter()
7828                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7829                .clone();
7830            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7831            Some((parent_run, sub, parent_path))
7832        });
7833
7834        if let Some((parent_run, sub, parent_path)) = rebuilt {
7835            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7836            let pp = pdf_path.clone();
7837            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7838        }
7839    }
7840
7841    if !pdf_path.exists() {
7842        let html = render_error_artifact_html(
7843            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7844             enabled."
7845                .to_string(),
7846            Some("/view-reports".to_string()),
7847            Some("View Reports".to_string()),
7848            Some(run_id.to_string()),
7849            Some(404),
7850            csp_nonce,
7851        );
7852        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7853    }
7854
7855    serve_pdf_artifact(
7856        &pdf_path,
7857        &artifact_set.report_title,
7858        run_id,
7859        wants_download,
7860        csp_nonce,
7861    )
7862}
7863
7864fn serve_submodule_arm(
7865    artifact: &str,
7866    artifact_set: &RunArtifacts,
7867    wants_download: bool,
7868    csp_nonce: &str,
7869    run_id: &str,
7870    server_mode: bool,
7871) -> Response {
7872    if artifact.len() > 128
7873        || !artifact
7874            .chars()
7875            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7876    {
7877        return StatusCode::BAD_REQUEST.into_response();
7878    }
7879    let filename = format!("{artifact}.html");
7880    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7881    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7882    let path = if new_layout.exists() {
7883        new_layout
7884    } else {
7885        artifact_set.output_dir.join(&filename)
7886    };
7887    if !path.exists() {
7888        let html = render_error_artifact_html(
7889            format!(
7890                "Sub-report '{artifact}' was not found in the run directory.\n\
7891                 Re-run the analysis with 'Detect and separate git submodules' \
7892                 and HTML output enabled."
7893            ),
7894            Some("/view-reports".to_string()),
7895            Some("View Reports".to_string()),
7896            Some(run_id.to_string()),
7897            Some(404),
7898            csp_nonce,
7899        );
7900        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7901    }
7902    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7903}
7904
7905async fn serve_pdf_arm(
7906    state: &AppState,
7907    artifact_set: RunArtifacts,
7908    wants_download: bool,
7909    run_id: &str,
7910    csp_nonce: &str,
7911) -> Response {
7912    let report_title = artifact_set.report_title.clone();
7913    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7914    let stale_html_name = artifact_set
7915        .html_path
7916        .as_deref()
7917        .and_then(|p| p.file_name())
7918        .map(|n| n.to_string_lossy().into_owned());
7919    let path = match resolve_or_queue_pdf(
7920        state,
7921        artifact_set.pdf_path,
7922        artifact_set.json_path.clone(),
7923        artifact_set.output_dir.clone(),
7924        run_id,
7925        &report_title,
7926        csp_nonce,
7927    )
7928    .await
7929    {
7930        Ok(p) => p,
7931        Err(r) => return r,
7932    };
7933    if !path.exists() {
7934        // Distinguish a stale registry path (folder moved) from an in-progress
7935        // background generation. Only show the locate page when the PDF was
7936        // already recorded in the registry but the file is now missing.
7937        if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
7938            let html = LocateFileTemplate {
7939                run_id: run_id.to_string(),
7940                artifact_type: "pdf".to_string(),
7941                expected_filename,
7942                server_mode: state.server_mode,
7943                csp_nonce: csp_nonce.to_string(),
7944                version: env!("CARGO_PKG_VERSION"),
7945            }
7946            .render()
7947            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7948            return (StatusCode::NOT_FOUND, Html(html)).into_response();
7949        }
7950        return pdf_generating_response(run_id, csp_nonce);
7951    }
7952    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7953}
7954
7955async fn artifact_handler(
7956    State(state): State<AppState>,
7957    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7958    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7959    Query(query): Query<ArtifactQuery>,
7960) -> Response {
7961    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7962        Ok(a) => a,
7963        Err(r) => return r,
7964    };
7965
7966    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7967
7968    match artifact.as_str() {
7969        "html" => {
7970            let Some(path) = artifact_set.html_path else {
7971                return StatusCode::NOT_FOUND.into_response();
7972            };
7973            serve_html_artifact(
7974                &path,
7975                wants_download,
7976                &csp_nonce,
7977                &run_id,
7978                state.server_mode,
7979            )
7980        }
7981        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7982        "json" => {
7983            let Some(path) = artifact_set.json_path else {
7984                let html = render_error_artifact_html(
7985                    "JSON result was not generated for this run, or was not recorded in \
7986                     the scan registry. Re-run the analysis with JSON output enabled."
7987                        .to_string(),
7988                    Some("/view-reports".to_string()),
7989                    Some("View Reports".to_string()),
7990                    Some(run_id.clone()),
7991                    Some(404),
7992                    &csp_nonce,
7993                );
7994                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7995            };
7996            serve_json_artifact(&path, wants_download, &csp_nonce)
7997        }
7998        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7999        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
8000        "scan-config" => serve_scan_config_arm(&artifact_set),
8001        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
8002            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
8003                .await
8004        }
8005        _ if artifact.starts_with("sub_") => serve_submodule_arm(
8006            &artifact,
8007            &artifact_set,
8008            wants_download,
8009            &csp_nonce,
8010            &run_id,
8011            state.server_mode,
8012        ),
8013        _ => StatusCode::NOT_FOUND.into_response(),
8014    }
8015}
8016
8017// ── History ───────────────────────────────────────────────────────────────────
8018
8019struct SubmoduleLinkRow {
8020    name: String,
8021    url: String,
8022}
8023
8024struct HistoryEntryRow {
8025    run_id: String,
8026    run_id_short: String,
8027    timestamp: String,
8028    timestamp_utc_ms: i64,
8029    project_label: String,
8030    project_path: String,
8031    files_analyzed: u64,
8032    files_skipped: u64,
8033    code_lines: u64,
8034    comment_lines: u64,
8035    blank_lines: u64,
8036    total_physical_lines: u64,
8037    functions: u64,
8038    classes: u64,
8039    variables: u64,
8040    imports: u64,
8041    test_count: u64,
8042    git_branch: String,
8043    git_commit: String,
8044    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
8045    git_commit_long: String,
8046    has_html: bool,
8047    has_json: bool,
8048    has_pdf: bool,
8049    submodule_links: Vec<SubmoduleLinkRow>,
8050    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
8051    submodule_names_csv: String,
8052}
8053
8054/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
8055fn nth_weekday_of_month(
8056    year: i32,
8057    month: u32,
8058    weekday: chrono::Weekday,
8059    n: u32,
8060) -> chrono::NaiveDate {
8061    use chrono::Datelike;
8062    let mut count = 0u32;
8063    let mut day = 1u32;
8064    loop {
8065        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
8066        if d.weekday() == weekday {
8067            count += 1;
8068            if count == n {
8069                return d;
8070            }
8071        }
8072        day += 1;
8073    }
8074}
8075
8076/// Returns true if `dt` falls within US Pacific Daylight Time.
8077/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
8078/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
8079fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
8080    use chrono::{Datelike, TimeZone};
8081    let year = dt.year();
8082    let dst_start = chrono::Utc.from_utc_datetime(
8083        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
8084            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
8085    );
8086    let dst_end = chrono::Utc.from_utc_datetime(
8087        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
8088            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
8089    );
8090    dt >= dst_start && dt < dst_end
8091}
8092
8093fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
8094    if is_pacific_dst(dt) {
8095        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
8096            .format("%Y-%m-%d %H:%M PDT")
8097            .to_string()
8098    } else {
8099        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
8100            .format("%Y-%m-%d %H:%M PST")
8101            .to_string()
8102    }
8103}
8104
8105/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
8106fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
8107    let (offset, tz) = if is_pacific_dst(dt) {
8108        (
8109            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
8110            "PDT",
8111        )
8112    } else {
8113        (
8114            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
8115            "PST",
8116        )
8117    };
8118    format!(
8119        "{} {tz}",
8120        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
8121    )
8122}
8123
8124fn fmt_git_date(iso: &str) -> Option<String> {
8125    chrono::DateTime::parse_from_rfc3339(iso)
8126        .ok()
8127        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
8128}
8129
8130/// Recover the full-length commit SHA for a registry entry whose stored record
8131/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
8132///
8133/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
8134/// is serialized after the per-file records, near the end of the file. We read a
8135/// bounded tail and pick the `git_commit_long` value whose hash begins with the
8136/// known short SHA — this disambiguates the super-repo commit from any submodule
8137/// commits that also appear. Returns `None` if the file is unreadable or no match.
8138fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
8139    use std::io::{Read, Seek, SeekFrom};
8140    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
8141    if short.is_empty() {
8142        return None;
8143    }
8144    let len = std::fs::metadata(path).ok()?.len();
8145    let start = len.saturating_sub(TAIL);
8146    let mut file = std::fs::File::open(path).ok()?;
8147    file.seek(SeekFrom::Start(start)).ok()?;
8148    let mut buf = Vec::new();
8149    file.read_to_end(&mut buf).ok()?;
8150    let text = String::from_utf8_lossy(&buf);
8151    let short_lower = short.to_ascii_lowercase();
8152    let key = "\"git_commit_long\"";
8153    let mut found: Option<String> = None;
8154    let mut cursor = 0usize;
8155    while let Some(idx) = text[cursor..].find(key) {
8156        let after_key = cursor + idx + key.len();
8157        cursor = after_key;
8158        let rest = &text[after_key..];
8159        let Some(colon) = rest.find(':') else { break };
8160        let value_region = rest[colon + 1..].trim_start();
8161        // Skip `null` (or any non-string) values without consuming the next field.
8162        if let Some(open) = value_region.strip_prefix('"')
8163            && let Some(close) = open.find('"')
8164        {
8165            let val = &open[..close];
8166            if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
8167                found = Some(val.to_string());
8168            }
8169        }
8170    }
8171    found
8172}
8173
8174fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
8175    reg.entries
8176        .iter()
8177        .map(|e| {
8178            let submodule_links = {
8179                let mut links: Vec<SubmoduleLinkRow> = vec![];
8180                let sub_dir = e
8181                    .html_path
8182                    .as_ref()
8183                    .and_then(|p| p.parent())
8184                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8185                if let Some(dir) = sub_dir
8186                    && let Ok(rd) = std::fs::read_dir(dir)
8187                {
8188                    for entry_res in rd.flatten() {
8189                        let fname = entry_res.file_name();
8190                        let fname_str = fname.to_string_lossy();
8191                        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8192                            let stem = &fname_str[..fname_str.len() - 5];
8193                            let display = stem[4..].replace('-', " ");
8194                            links.push(SubmoduleLinkRow {
8195                                name: display,
8196                                url: format!("/runs/{stem}/{}", e.run_id),
8197                            });
8198                        }
8199                    }
8200                }
8201                links.sort_by(|a, b| a.name.cmp(&b.name));
8202                links
8203            };
8204            let submodule_names_csv = submodule_links
8205                .iter()
8206                .map(|l| l.name.as_str())
8207                .collect::<Vec<_>>()
8208                .join(",");
8209            HistoryEntryRow {
8210                run_id: e.run_id.clone(),
8211                run_id_short: e
8212                    .run_id
8213                    .split('-')
8214                    .next_back()
8215                    .unwrap_or(&e.run_id)
8216                    .chars()
8217                    .take(7)
8218                    .collect(),
8219                timestamp: fmt_la_time(e.timestamp_utc),
8220                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
8221                project_label: e.project_label.clone(),
8222                project_path: e
8223                    .input_roots
8224                    .first()
8225                    .map(|s| sanitize_path_str(s))
8226                    .unwrap_or_default(),
8227                files_analyzed: e.summary.files_analyzed,
8228                files_skipped: e.summary.files_skipped,
8229                code_lines: e.summary.code_lines,
8230                comment_lines: e.summary.comment_lines,
8231                blank_lines: e.summary.blank_lines,
8232                total_physical_lines: e.summary.total_physical_lines,
8233                functions: e.summary.functions,
8234                classes: e.summary.classes,
8235                variables: e.summary.variables,
8236                imports: e.summary.imports,
8237                test_count: e.summary.test_count,
8238                git_branch: e.git_branch.clone().unwrap_or_default(),
8239                git_commit: e.git_commit.clone().unwrap_or_default(),
8240                git_commit_long: {
8241                    let short = e.git_commit.clone().unwrap_or_default();
8242                    e.git_commit_long
8243                        .clone()
8244                        .filter(|s| !s.is_empty())
8245                        .or_else(|| {
8246                            e.json_path
8247                                .as_ref()
8248                                .and_then(|p| extract_long_commit_from_json(p, &short))
8249                        })
8250                        .unwrap_or(short)
8251                },
8252                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8253                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8254                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8255                submodule_links,
8256                submodule_names_csv,
8257            }
8258        })
8259        .collect()
8260}
8261
8262#[derive(Deserialize, Default)]
8263struct HistoryQuery {
8264    linked: Option<String>,
8265    error: Option<String>,
8266}
8267
8268async fn history_handler(
8269    State(state): State<AppState>,
8270    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8271    Query(query): Query<HistoryQuery>,
8272) -> impl IntoResponse {
8273    // Auto-scan all watched directories before rendering so the list stays fresh.
8274    auto_scan_watched_dirs(&state).await;
8275    let watched_dirs: Vec<String> = {
8276        let wd = state.watched_dirs.lock().await;
8277        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8278    };
8279    let mut entries = {
8280        let reg = state.registry.lock().await;
8281        make_history_rows(&reg)
8282    };
8283    entries.retain(|e| e.has_html);
8284    let total_scans = entries.len();
8285    let linked_count = query
8286        .linked
8287        .as_deref()
8288        .and_then(|s| s.parse::<usize>().ok())
8289        .unwrap_or(0);
8290    let browse_error = query.error.filter(|s| !s.is_empty());
8291    let template = HistoryTemplate {
8292        version: env!("CARGO_PKG_VERSION"),
8293        entries,
8294        total_scans,
8295        linked_count,
8296        browse_error,
8297        watched_dirs,
8298        csp_nonce,
8299        server_mode: state.server_mode,
8300    };
8301    Html(
8302        template
8303            .render()
8304            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8305    )
8306    .into_response()
8307}
8308
8309async fn compare_select_handler(
8310    State(state): State<AppState>,
8311    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8312) -> impl IntoResponse {
8313    auto_scan_watched_dirs(&state).await;
8314    let watched_dirs: Vec<String> = {
8315        let wd = state.watched_dirs.lock().await;
8316        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8317    };
8318    let mut entries = {
8319        let reg = state.registry.lock().await;
8320        make_history_rows(&reg)
8321    };
8322    entries.retain(|e| e.has_json);
8323    let total_scans = entries.len();
8324    let template = CompareSelectTemplate {
8325        version: env!("CARGO_PKG_VERSION"),
8326        entries,
8327        total_scans,
8328        watched_dirs,
8329        csp_nonce,
8330        server_mode: state.server_mode,
8331    };
8332    Html(
8333        template
8334            .render()
8335            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8336    )
8337    .into_response()
8338}
8339
8340// ── Compare ───────────────────────────────────────────────────────────────────
8341
8342#[derive(Deserialize, Default)]
8343struct CompareQuery {
8344    a: Option<String>,
8345    b: Option<String>,
8346    /// Optional submodule name to scope the comparison to one submodule.
8347    sub: Option<String>,
8348    /// "super" to exclude all submodule files and show only the super-repo.
8349    scope: Option<String>,
8350}
8351
8352struct CompareFileDeltaRow {
8353    relative_path: String,
8354    language: String,
8355    status: String,
8356    baseline_code: i64,
8357    current_code: i64,
8358    baseline_code_display: String,
8359    current_code_display: String,
8360    code_delta_str: String,
8361    code_delta_class: String,
8362    comment_delta_str: String,
8363    comment_delta_class: String,
8364    total_delta_str: String,
8365    total_delta_class: String,
8366}
8367
8368/// Recompute `summary_totals` from the current `per_file_records` slice.
8369/// Used when `per_file_records` has been narrowed to a submodule subset.
8370fn recompute_summary_from_records(run: &mut AnalysisRun) {
8371    let mut totals = SummaryTotals::default();
8372    for r in &run.per_file_records {
8373        if r.language.is_some() {
8374            totals.files_analyzed += 1;
8375        }
8376        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8377        totals.code_lines += r.effective_counts.code_lines;
8378        totals.comment_lines += r.effective_counts.comment_lines;
8379        totals.blank_lines += r.effective_counts.blank_lines;
8380        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8381        totals.functions += r.raw_line_categories.functions;
8382        totals.classes += r.raw_line_categories.classes;
8383        totals.variables += r.raw_line_categories.variables;
8384        totals.imports += r.raw_line_categories.imports;
8385        totals.test_count += r.raw_line_categories.test_count;
8386        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8387        totals.test_suite_count += r.raw_line_categories.test_suite_count;
8388        if let Some(cov) = &r.coverage {
8389            totals.coverage_lines_found += u64::from(cov.lines_found);
8390            totals.coverage_lines_hit += u64::from(cov.lines_hit);
8391            totals.coverage_functions_found += u64::from(cov.functions_found);
8392            totals.coverage_functions_hit += u64::from(cov.functions_hit);
8393            totals.coverage_branches_found += u64::from(cov.branches_found);
8394            totals.coverage_branches_hit += u64::from(cov.branches_hit);
8395        }
8396    }
8397    totals.files_considered = totals.files_analyzed;
8398    run.summary_totals = totals;
8399}
8400
8401fn fmt_delta(n: i64) -> String {
8402    if n > 0 {
8403        format!("+{n}")
8404    } else {
8405        format!("{n}")
8406    }
8407}
8408
8409fn delta_class(n: i64) -> &'static str {
8410    use std::cmp::Ordering;
8411    match n.cmp(&0) {
8412        Ordering::Greater => "pos",
8413        Ordering::Less => "neg",
8414        Ordering::Equal => "zero",
8415    }
8416}
8417
8418// ratio/percentage display, precision loss acceptable
8419#[allow(clippy::cast_precision_loss)]
8420fn fmt_pct(delta: i64, baseline: u64) -> String {
8421    if baseline == 0 {
8422        return "—".to_string();
8423    }
8424    #[allow(clippy::cast_precision_loss)]
8425    let pct = (delta as f64 / baseline as f64) * 100.0;
8426    if pct > 0.049 {
8427        format!("+{pct:.1}%")
8428    } else if pct < -0.049 {
8429        format!("{pct:.1}%")
8430    } else {
8431        "±0%".to_string()
8432    }
8433}
8434
8435/// Returns (`display_string`, `css_class`) for a numeric change column cell.
8436fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8437    prev.map_or_else(
8438        || ("—".to_string(), "na"),
8439        |p| {
8440            #[allow(clippy::cast_possible_wrap)]
8441            let d = curr as i64 - p as i64;
8442            (fmt_delta(d), delta_class(d))
8443        },
8444    )
8445}
8446
8447#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
8448fn load_scan_for_compare(
8449    json_path: &std::path::Path,
8450    scan_label: &str,
8451    run_id: &str,
8452    server_mode: bool,
8453    compare_url: &str,
8454    csp_nonce: &str,
8455) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8456    match read_json(json_path) {
8457        Ok(r) => Ok(r),
8458        Err(e) => {
8459            if server_mode {
8460                let html = ErrorTemplate {
8461                    message: format!(
8462                        "Could not load {scan_label} scan data. The scan output folder may have \
8463                         been moved, renamed, or deleted. Re-running the analysis will create \
8464                         fresh comparison data."
8465                    ),
8466                    last_report_url: Some("/compare-scans".to_string()),
8467                    last_report_label: Some("Compare Scans".to_string()),
8468                    run_id: Some(run_id.to_owned()),
8469                    error_code: Some(404),
8470                    csp_nonce: csp_nonce.to_owned(),
8471                    version: env!("CARGO_PKG_VERSION"),
8472                }
8473                .render()
8474                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8475                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8476            }
8477            let msg = format!(
8478                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8479                json_path.display()
8480            );
8481            let folder_hint = output_folder_hint(json_path);
8482            Err(missing_scan_relocate_response(
8483                &msg,
8484                run_id,
8485                &folder_hint,
8486                compare_url,
8487                false,
8488                csp_nonce,
8489            ))
8490        }
8491    }
8492}
8493
8494struct ChurnStats {
8495    new_scope: bool,
8496    scope_flag: bool,
8497    churn_rate_str: String,
8498    churn_rate_class: String,
8499}
8500
8501fn compute_churn_stats(
8502    baseline_code: u64,
8503    current_code: u64,
8504    lines_added: i64,
8505    lines_removed: i64,
8506) -> ChurnStats {
8507    let new_scope = baseline_code == 0 && current_code > 0;
8508    #[allow(clippy::cast_precision_loss)]
8509    let churn_pct = if baseline_code > 0 {
8510        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8511    } else {
8512        0.0
8513    };
8514    #[allow(clippy::cast_precision_loss)]
8515    let scope_flag =
8516        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8517    let churn_rate_str = if new_scope {
8518        "New".to_string()
8519    } else if baseline_code > 0 {
8520        format!("{churn_pct:.1}%")
8521    } else {
8522        "—".to_string()
8523    };
8524    let churn_rate_class = if new_scope || churn_pct > 20.0 {
8525        "high".to_string()
8526    } else if churn_pct > 5.0 {
8527        "med".to_string()
8528    } else {
8529        "low".to_string()
8530    };
8531    ChurnStats {
8532        new_scope,
8533        scope_flag,
8534        churn_rate_str,
8535        churn_rate_class,
8536    }
8537}
8538
8539/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
8540/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
8541/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
8542fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8543    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8544    if !has_data {
8545        return String::new();
8546    }
8547    let base_str = s
8548        .baseline_coverage_line_pct
8549        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8550    let curr_str = s
8551        .current_coverage_line_pct
8552        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8553    let (delta_str, cls) = match s.coverage_line_pct_delta {
8554        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8555        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8556        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8557        None => ("\u{2014}".into(), "zero"),
8558    };
8559    format!(
8560        r#"<div class="delta-card">
8561          <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>
8562          <div class="delta-card-label">Line coverage</div>
8563          <div class="delta-card-from">Before: {base_str}</div>
8564          <div class="delta-card-to">{curr_str}</div>
8565          <span class="delta-card-change {cls}">{delta_str}</span>
8566        </div>"#
8567    )
8568}
8569
8570/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
8571#[allow(clippy::ref_option)]
8572fn narrow_run_pair_by_scope(
8573    mut baseline: AnalysisRun,
8574    mut current: AnalysisRun,
8575    active_sub: &Option<String>,
8576    super_scope: bool,
8577) -> (AnalysisRun, AnalysisRun) {
8578    if let Some(sub_name) = active_sub {
8579        baseline
8580            .per_file_records
8581            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8582        current
8583            .per_file_records
8584            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8585        recompute_summary_from_records(&mut baseline);
8586        recompute_summary_from_records(&mut current);
8587    } else if super_scope {
8588        baseline.per_file_records.retain(|f| f.submodule.is_none());
8589        current.per_file_records.retain(|f| f.submodule.is_none());
8590        recompute_summary_from_records(&mut baseline);
8591        recompute_summary_from_records(&mut current);
8592    }
8593    (baseline, current)
8594}
8595
8596/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
8597#[allow(clippy::ref_option)]
8598fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8599    if let Some(sub_name) = active_sub {
8600        for run in runs.iter_mut() {
8601            run.per_file_records
8602                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8603            recompute_summary_from_records(run);
8604        }
8605    } else if super_scope {
8606        for run in runs.iter_mut() {
8607            run.per_file_records.retain(|f| f.submodule.is_none());
8608            recompute_summary_from_records(run);
8609        }
8610    }
8611}
8612
8613#[allow(clippy::too_many_lines)]
8614async fn compare_handler(
8615    State(state): State<AppState>,
8616    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8617    Query(query): Query<CompareQuery>,
8618) -> impl IntoResponse {
8619    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
8620    // redirect to the history page where the user can select two runs.
8621    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8622        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8623        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8624    };
8625
8626    let (maybe_a, maybe_b) = {
8627        let reg = state.registry.lock().await;
8628        (
8629            reg.find_by_run_id(&run_id_a).cloned(),
8630            reg.find_by_run_id(&run_id_b).cloned(),
8631        )
8632    };
8633
8634    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8635        let html = ErrorTemplate {
8636            message: "One or both run IDs were not found in scan history. \
8637                      The runs may have been deleted or the registry may have been reset."
8638                .to_string(),
8639            last_report_url: Some("/compare-scans".to_string()),
8640            last_report_label: Some("Compare Scans".to_string()),
8641            run_id: None,
8642            error_code: None,
8643            csp_nonce: csp_nonce.clone(),
8644            version: env!("CARGO_PKG_VERSION"),
8645        }
8646        .render()
8647        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8648        return Html(html).into_response();
8649    };
8650
8651    // Ensure older scan is always the baseline.
8652    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8653        (entry_a, entry_b)
8654    } else {
8655        (entry_b, entry_a)
8656    };
8657
8658    // If query params were in the wrong order, redirect to canonical URL so the
8659    // browser always shows the same URL for the same two scans regardless of how
8660    // the user arrived here (Full diff button vs. Compare Scans selection).
8661    if baseline_entry.run_id != run_id_a {
8662        let canonical = format!(
8663            "/compare?a={}&b={}",
8664            baseline_entry.run_id, current_entry.run_id
8665        );
8666        return axum::response::Redirect::to(&canonical).into_response();
8667    }
8668
8669    let (Some(base_json), Some(curr_json)) = (
8670        baseline_entry.json_path.as_ref(),
8671        current_entry.json_path.as_ref(),
8672    ) else {
8673        let html = ErrorTemplate {
8674            message: "Full comparison requires JSON scan data, which was not saved for one or \
8675                      both of these runs. JSON is now always saved for new scans — re-run the \
8676                      affected projects to enable comparisons."
8677                .to_string(),
8678            last_report_url: Some("/compare-scans".to_string()),
8679            last_report_label: Some("Compare Scans".to_string()),
8680            run_id: None,
8681            error_code: None,
8682            csp_nonce: csp_nonce.clone(),
8683            version: env!("CARGO_PKG_VERSION"),
8684        }
8685        .render()
8686        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8687        return Html(html).into_response();
8688    };
8689
8690    let compare_url = format!(
8691        "/compare?a={}&b={}",
8692        baseline_entry.run_id, current_entry.run_id
8693    );
8694
8695    let baseline_run = match load_scan_for_compare(
8696        base_json,
8697        "baseline",
8698        &baseline_entry.run_id,
8699        state.server_mode,
8700        &compare_url,
8701        &csp_nonce,
8702    ) {
8703        Ok(r) => r,
8704        Err(resp) => return resp,
8705    };
8706    let current_run = match load_scan_for_compare(
8707        curr_json,
8708        "current",
8709        &current_entry.run_id,
8710        state.server_mode,
8711        &compare_url,
8712        &csp_nonce,
8713    ) {
8714        Ok(r) => r,
8715        Err(resp) => return resp,
8716    };
8717
8718    let active_submodule = query.sub.clone();
8719    let super_scope_active = query.scope.as_deref() == Some("super");
8720
8721    let submodule_options = baseline_run
8722        .submodule_summaries
8723        .iter()
8724        .chain(current_run.submodule_summaries.iter())
8725        .map(|s| s.name.clone())
8726        .collect::<std::collections::BTreeSet<_>>()
8727        .into_iter()
8728        .collect::<Vec<_>>();
8729    let has_any_submodule_data = !submodule_options.is_empty();
8730
8731    // Narrow per_file_records when a scope is active, then recompute totals.
8732    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8733        baseline_run,
8734        current_run,
8735        &active_submodule,
8736        super_scope_active,
8737    );
8738
8739    let comparison = compute_delta(&effective_baseline, &effective_current);
8740
8741    let file_rows: Vec<CompareFileDeltaRow> = comparison
8742        .file_deltas
8743        .iter()
8744        .map(|d| CompareFileDeltaRow {
8745            relative_path: d.relative_path.clone(),
8746            language: d.language.clone().unwrap_or_else(|| "—".into()),
8747            status: match d.status {
8748                FileChangeStatus::Added => "added".into(),
8749                FileChangeStatus::Removed => "removed".into(),
8750                FileChangeStatus::Modified => "modified".into(),
8751                FileChangeStatus::Unchanged => "unchanged".into(),
8752            },
8753            baseline_code: d.baseline_code,
8754            current_code: d.current_code,
8755            baseline_code_display: if d.status == FileChangeStatus::Added {
8756                "—".into()
8757            } else {
8758                d.baseline_code.to_string()
8759            },
8760            current_code_display: if d.status == FileChangeStatus::Removed {
8761                "—".into()
8762            } else {
8763                d.current_code.to_string()
8764            },
8765            code_delta_str: fmt_delta(d.code_delta),
8766            code_delta_class: delta_class(d.code_delta).into(),
8767            comment_delta_str: fmt_delta(d.comment_delta),
8768            comment_delta_class: delta_class(d.comment_delta).into(),
8769            total_delta_str: fmt_delta(d.total_delta),
8770            total_delta_class: delta_class(d.total_delta).into(),
8771        })
8772        .collect();
8773
8774    let project_path = baseline_entry
8775        .input_roots
8776        .first()
8777        .map(|s| sanitize_path_str(s))
8778        .unwrap_or_default();
8779    let lines_added = sum_added_code_lines(&comparison);
8780    let lines_removed = sum_removed_code_lines(&comparison);
8781    let churn = compute_churn_stats(
8782        comparison.summary.baseline_code,
8783        comparison.summary.current_code,
8784        lines_added,
8785        lines_removed,
8786    );
8787    let s = &comparison.summary;
8788    let template = CompareTemplate {
8789        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8790        version: env!("CARGO_PKG_VERSION"),
8791        project_label: baseline_entry.project_label.clone(),
8792        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8793        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8794        baseline_run_id: baseline_entry.run_id.clone(),
8795        current_run_id: current_entry.run_id.clone(),
8796        baseline_run_id_short: baseline_entry
8797            .run_id
8798            .split('-')
8799            .next_back()
8800            .unwrap_or(&baseline_entry.run_id)
8801            .chars()
8802            .take(7)
8803            .collect(),
8804        current_run_id_short: current_entry
8805            .run_id
8806            .split('-')
8807            .next_back()
8808            .unwrap_or(&current_entry.run_id)
8809            .chars()
8810            .take(7)
8811            .collect(),
8812        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8813        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8814        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8815        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8816        project_path: project_path.clone(),
8817        baseline_code: s.baseline_code,
8818        current_code: s.current_code,
8819        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8820        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8821        baseline_files: s.baseline_files,
8822        current_files: s.current_files,
8823        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8824        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8825        baseline_comments: s.baseline_comments,
8826        current_comments: s.current_comments,
8827        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8828        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8829        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8830        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8831        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8832        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8833        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8834        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8835        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8836        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8837        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8838        code_lines_added: lines_added,
8839        code_lines_removed: lines_removed,
8840        code_lines_modified: sum_modified_code_lines(&comparison),
8841        code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8842        code_lines_total: lines_added
8843            + lines_removed
8844            + sum_modified_code_lines(&comparison)
8845            + sum_unmodified_code_lines(&comparison),
8846        new_scope: churn.new_scope,
8847        churn_rate_str: churn.churn_rate_str,
8848        churn_rate_class: churn.churn_rate_class,
8849        scope_flag: churn.scope_flag,
8850        files_added: comparison.files_added,
8851        files_removed: comparison.files_removed,
8852        files_modified: comparison.files_modified,
8853        files_unchanged: comparison.files_unchanged,
8854        files_total: comparison.files_total,
8855        file_rows,
8856        baseline_git_author: baseline_entry.git_author.clone(),
8857        current_git_author: current_entry.git_author.clone(),
8858        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8859        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8860        baseline_git_tags: baseline_entry.git_tags.clone(),
8861        current_git_tags: current_entry.git_tags.clone(),
8862        baseline_git_commit_date: baseline_entry
8863            .git_commit_date
8864            .as_deref()
8865            .and_then(fmt_git_date),
8866        current_git_commit_date: current_entry
8867            .git_commit_date
8868            .as_deref()
8869            .and_then(fmt_git_date),
8870        project_name: project_path
8871            .rsplit(['/', '\\'])
8872            .find(|s| !s.is_empty())
8873            .unwrap_or(&project_path)
8874            .to_string(),
8875        submodule_options,
8876        has_any_submodule_data,
8877        active_submodule,
8878        super_scope_active,
8879        toast_assets: sloc_toast_assets(&csp_nonce),
8880        csp_nonce,
8881        coverage_delta_card: build_coverage_delta_card(s),
8882        baseline_test_count: effective_baseline.summary_totals.test_count,
8883        current_test_count: effective_current.summary_totals.test_count,
8884        baseline_coverage_pct: s.baseline_coverage_line_pct,
8885        current_coverage_pct: s.current_coverage_line_pct,
8886    };
8887
8888    Html(
8889        template
8890            .render()
8891            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8892    )
8893    .into_response()
8894}
8895
8896// ── Badge endpoint ────────────────────────────────────────────────────────────
8897// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8898// pages, Jira descriptions, etc.
8899//
8900// GET /badge/<metric>?label=<override>&color=<hex>
8901// Metrics: code-lines  files  comment-lines  blank-lines
8902
8903fn format_number(n: u64) -> String {
8904    let s = n.to_string();
8905    let mut out = String::with_capacity(s.len() + s.len() / 3);
8906    let len = s.len();
8907    for (i, c) in s.chars().enumerate() {
8908        if i > 0 && (len - i).is_multiple_of(3) {
8909            out.push(',');
8910        }
8911        out.push(c);
8912    }
8913    out
8914}
8915
8916const fn badge_char_width(c: char) -> f64 {
8917    match c {
8918        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8919        'm' | 'w' => 9.0,
8920        ' ' => 4.0,
8921        _ => 6.5,
8922    }
8923}
8924
8925#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8926fn badge_text_px(text: &str) -> u32 {
8927    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8928}
8929
8930fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8931    let lw = badge_text_px(label) + 20;
8932    let rw = badge_text_px(value) + 20;
8933    let total = lw + rw;
8934    let lx = lw / 2;
8935    let rx = lw + rw / 2;
8936    let le = escape_html(label);
8937    let ve = escape_html(value);
8938    let ce = escape_html(color);
8939    format!(
8940        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8941  <rect width="{total}" height="20" fill="#555"/>
8942  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8943  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8944    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8945    <text x="{lx}" y="13">{le}</text>
8946    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8947    <text x="{rx}" y="13">{ve}</text>
8948  </g>
8949</svg>"##
8950    )
8951}
8952
8953#[derive(Deserialize)]
8954struct BadgeQuery {
8955    label: Option<String>,
8956    color: Option<String>,
8957}
8958
8959async fn badge_handler(
8960    State(state): State<AppState>,
8961    AxumPath(metric): AxumPath<String>,
8962    Query(query): Query<BadgeQuery>,
8963) -> Response {
8964    let entry = {
8965        let reg = state.registry.lock().await;
8966        reg.entries.first().cloned()
8967    };
8968
8969    let Some(entry) = entry else {
8970        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8971        return (
8972            [
8973                (header::CONTENT_TYPE, "image/svg+xml"),
8974                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8975            ],
8976            svg,
8977        )
8978            .into_response();
8979    };
8980
8981    let (default_label, value, default_color) = match metric.as_str() {
8982        "code-lines" => (
8983            "code lines",
8984            format_number(entry.summary.code_lines),
8985            "#4a78ee",
8986        ),
8987        "files" => (
8988            "files analyzed",
8989            format_number(entry.summary.files_analyzed),
8990            "#4a9862",
8991        ),
8992        "comment-lines" => (
8993            "comment lines",
8994            format_number(entry.summary.comment_lines),
8995            "#b35428",
8996        ),
8997        "blank-lines" => (
8998            "blank lines",
8999            format_number(entry.summary.blank_lines),
9000            "#7a5db0",
9001        ),
9002        _ => return StatusCode::NOT_FOUND.into_response(),
9003    };
9004
9005    let label = query.label.as_deref().unwrap_or(default_label);
9006    let color = query.color.as_deref().unwrap_or(default_color);
9007    let svg = render_badge_svg(label, &value, color);
9008
9009    (
9010        [
9011            (header::CONTENT_TYPE, "image/svg+xml"),
9012            (header::CACHE_CONTROL, "no-cache, max-age=0"),
9013        ],
9014        svg,
9015    )
9016        .into_response()
9017}
9018
9019// ── Metrics API ───────────────────────────────────────────────────────────────
9020// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
9021// Confluence automation, Jira webhooks, etc.
9022//
9023// GET /api/metrics/latest
9024// GET /api/metrics/<run_id>
9025
9026#[derive(Serialize)]
9027struct ApiCoverageBlock {
9028    lines_found: u64,
9029    lines_hit: u64,
9030    line_pct: f64,
9031    functions_found: u64,
9032    functions_hit: u64,
9033    function_pct: f64,
9034    branches_found: u64,
9035    branches_hit: u64,
9036    branch_pct: f64,
9037}
9038
9039#[derive(Serialize)]
9040struct ApiMetricsResponse {
9041    run_id: String,
9042    timestamp: String,
9043    project: String,
9044    summary: ApiSummaryPayload,
9045    languages: Vec<ApiLanguageRow>,
9046    #[serde(skip_serializing_if = "Option::is_none")]
9047    coverage: Option<ApiCoverageBlock>,
9048}
9049
9050#[derive(Serialize)]
9051struct ApiSummaryPayload {
9052    files_analyzed: u64,
9053    files_skipped: u64,
9054    code_lines: u64,
9055    comment_lines: u64,
9056    blank_lines: u64,
9057    total_physical_lines: u64,
9058    functions: u64,
9059    classes: u64,
9060    variables: u64,
9061    imports: u64,
9062}
9063
9064#[derive(Serialize)]
9065struct ApiLanguageRow {
9066    name: String,
9067    files: u64,
9068    code_lines: u64,
9069    comment_lines: u64,
9070    blank_lines: u64,
9071    functions: u64,
9072    classes: u64,
9073    variables: u64,
9074    imports: u64,
9075}
9076
9077async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
9078    let entry = {
9079        let reg = state.registry.lock().await;
9080        reg.entries.first().cloned()
9081    };
9082    entry.map_or_else(
9083        || error::not_found("no scans recorded yet"),
9084        |e| build_metrics_response(&e),
9085    )
9086}
9087
9088async fn api_metrics_run_handler(
9089    State(state): State<AppState>,
9090    AxumPath(run_id): AxumPath<String>,
9091) -> Response {
9092    let entry = {
9093        let reg = state.registry.lock().await;
9094        reg.find_by_run_id(&run_id).cloned()
9095    };
9096    entry.map_or_else(
9097        || error::not_found("run not found"),
9098        |e| build_metrics_response(&e),
9099    )
9100}
9101
9102fn build_metrics_response(entry: &RegistryEntry) -> Response {
9103    let languages: Vec<ApiLanguageRow> = entry
9104        .json_path
9105        .as_ref()
9106        .and_then(|p| read_json(p).ok())
9107        .map(|run| {
9108            run.totals_by_language
9109                .iter()
9110                .map(|l| ApiLanguageRow {
9111                    name: l.language.display_name().to_string(),
9112                    files: l.files,
9113                    code_lines: l.code_lines,
9114                    comment_lines: l.comment_lines,
9115                    blank_lines: l.blank_lines,
9116                    functions: l.functions,
9117                    classes: l.classes,
9118                    variables: l.variables,
9119                    imports: l.imports,
9120                })
9121                .collect()
9122        })
9123        .unwrap_or_default();
9124
9125    let s = &entry.summary;
9126    let coverage = if s.coverage_lines_found > 0 {
9127        let pct = |hit: u64, found: u64| -> f64 {
9128            if found == 0 {
9129                0.0
9130            } else {
9131                #[allow(clippy::cast_precision_loss)]
9132                let v = (hit as f64 / found as f64) * 100.0;
9133                (v * 10.0).round() / 10.0
9134            }
9135        };
9136        Some(ApiCoverageBlock {
9137            lines_found: s.coverage_lines_found,
9138            lines_hit: s.coverage_lines_hit,
9139            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
9140            functions_found: s.coverage_functions_found,
9141            functions_hit: s.coverage_functions_hit,
9142            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
9143            branches_found: s.coverage_branches_found,
9144            branches_hit: s.coverage_branches_hit,
9145            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
9146        })
9147    } else {
9148        None
9149    };
9150    Json(ApiMetricsResponse {
9151        run_id: entry.run_id.clone(),
9152        timestamp: entry.timestamp_utc.to_rfc3339(),
9153        project: entry.project_label.clone(),
9154        summary: ApiSummaryPayload {
9155            files_analyzed: s.files_analyzed,
9156            files_skipped: s.files_skipped,
9157            code_lines: s.code_lines,
9158            comment_lines: s.comment_lines,
9159            blank_lines: s.blank_lines,
9160            total_physical_lines: s.total_physical_lines,
9161            functions: s.functions,
9162            classes: s.classes,
9163            variables: s.variables,
9164            imports: s.imports,
9165        },
9166        languages,
9167        coverage,
9168    })
9169    .into_response()
9170}
9171
9172// ── Project history API ───────────────────────────────────────────────────────
9173// Protected. Called by the wizard JS when the project path changes, so the UI
9174// can show a "scanned N times before" badge without a full page reload.
9175//
9176// GET /api/project-history?path=<project_root>
9177
9178#[derive(Deserialize)]
9179struct ProjectHistoryQuery {
9180    path: Option<String>,
9181}
9182
9183#[derive(Serialize)]
9184struct ProjectHistoryResponse {
9185    scan_count: usize,
9186    last_scan_id: Option<String>,
9187    last_scan_timestamp: Option<String>,
9188    last_scan_code_lines: Option<u64>,
9189    last_git_branch: Option<String>,
9190    last_git_commit: Option<String>,
9191}
9192
9193/// Return true if `entry` matches either an exact root path or an upload-staging
9194/// path with the same project name (needed because each upload gets a fresh UUID dir).
9195fn entry_matches_project(
9196    entry: &RegistryEntry,
9197    root_str: &str,
9198    upload_root: &str,
9199    upload_name_suffix: Option<&str>,
9200) -> bool {
9201    if entry.input_roots.iter().any(|r| r == root_str) {
9202        return true;
9203    }
9204    if let Some(suffix) = upload_name_suffix {
9205        return entry
9206            .input_roots
9207            .iter()
9208            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
9209    }
9210    false
9211}
9212
9213async fn project_history_handler(
9214    State(state): State<AppState>,
9215    Query(query): Query<ProjectHistoryQuery>,
9216) -> Response {
9217    let path = query.path.unwrap_or_default();
9218    let resolved = resolve_input_path(&path);
9219    let root_str = resolved.to_string_lossy().replace('\\', "/");
9220
9221    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
9222    // The UUID is freshly generated for every upload, so an exact root_str match never finds
9223    // previous scans of the same project. Fall back to matching by project name within the
9224    // uploads staging directory so Scan History populates correctly across uploads.
9225    let upload_root = std::env::temp_dir()
9226        .join("oxide-sloc-uploads")
9227        .to_string_lossy()
9228        .replace('\\', "/");
9229    let upload_name_suffix: Option<String> =
9230        if state.server_mode && root_str.starts_with(&upload_root) {
9231            resolved
9232                .file_name()
9233                .and_then(|n| n.to_str())
9234                .map(|name| format!("/{name}"))
9235        } else {
9236            None
9237        };
9238    let suffix_ref = upload_name_suffix.as_deref();
9239
9240    let entries: Vec<_> = {
9241        let reg = state.registry.lock().await;
9242        reg.entries
9243            .iter()
9244            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9245            .cloned()
9246            .collect()
9247    };
9248    let scan_count = entries.len();
9249    let last = entries.first();
9250    let last_scan_id = last.map(|e| e.run_id.clone());
9251    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9252    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9253    let last_git_branch = last.and_then(|e| e.git_branch.clone());
9254    let last_git_commit = last.and_then(|e| e.git_commit.clone());
9255
9256    Json(ProjectHistoryResponse {
9257        scan_count,
9258        last_scan_id,
9259        last_scan_timestamp,
9260        last_scan_code_lines,
9261        last_git_branch,
9262        last_git_commit,
9263    })
9264    .into_response()
9265}
9266
9267// ── Metrics history API ───────────────────────────────────────────────────────
9268// Protected. Returns a JSON array of lightweight scan snapshots for plotting
9269// trend charts.
9270//
9271// GET /api/metrics/history?root=<path>&limit=<n>
9272
9273#[derive(Deserialize)]
9274struct MetricsHistoryQuery {
9275    root: Option<String>,
9276    limit: Option<usize>,
9277    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
9278    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
9279    submodule: Option<String>,
9280}
9281
9282#[derive(Serialize)]
9283struct MetricsSubmoduleLink {
9284    name: String,
9285    url: String,
9286}
9287
9288#[derive(Serialize)]
9289struct MetricsHistoryEntry {
9290    run_id: String,
9291    run_id_short: String,
9292    timestamp: String,
9293    commit: Option<String>,
9294    branch: Option<String>,
9295    tags: Vec<String>,
9296    nearest_tag: Option<String>,
9297    code_lines: u64,
9298    comment_lines: u64,
9299    blank_lines: u64,
9300    physical_lines: u64,
9301    files_analyzed: u64,
9302    files_skipped: u64,
9303    test_count: u64,
9304    project_label: String,
9305    html_url: Option<String>,
9306    has_pdf: bool,
9307    submodule_links: Vec<MetricsSubmoduleLink>,
9308    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
9309    #[serde(skip_serializing_if = "Option::is_none")]
9310    coverage_line_pct: Option<f64>,
9311}
9312
9313fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9314    let mut links: Vec<MetricsSubmoduleLink> = vec![];
9315    let sub_dir = e
9316        .html_path
9317        .as_ref()
9318        .and_then(|p| p.parent())
9319        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9320    let Some(dir) = sub_dir else { return links };
9321    let Ok(rd) = std::fs::read_dir(dir) else {
9322        return links;
9323    };
9324    for entry_res in rd.flatten() {
9325        let fname = entry_res.file_name();
9326        let fname_str = fname.to_string_lossy();
9327        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9328            let stem = &fname_str[..fname_str.len() - 5];
9329            let display = stem[4..].replace('-', " ");
9330            links.push(MetricsSubmoduleLink {
9331                name: display,
9332                url: format!("/runs/{stem}/{}", e.run_id),
9333            });
9334        }
9335    }
9336    links.sort_by(|a, b| a.name.cmp(&b.name));
9337    links
9338}
9339
9340fn apply_submodule_filter(
9341    base: MetricsHistoryEntry,
9342    filter: &str,
9343    e: &sloc_core::history::RegistryEntry,
9344) -> Option<MetricsHistoryEntry> {
9345    let json_path = e.json_path.as_ref()?;
9346    let json_str = std::fs::read_to_string(json_path).ok()?;
9347    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9348    let sub = run
9349        .submodule_summaries
9350        .iter()
9351        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9352    let safe = sanitize_project_label(&sub.name);
9353    let artifact_key = format!("sub_{safe}");
9354    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9355        || base.html_url.clone(),
9356        |run_dir| {
9357            let sub_path = run_dir.join(format!("{artifact_key}.html"));
9358            if sub_path.exists() {
9359                Some(format!("/runs/{artifact_key}/{}", e.run_id))
9360            } else {
9361                base.html_url.clone()
9362            }
9363        },
9364    );
9365
9366    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
9367    // basic SLOC totals, so test_count and coverage must be computed from file records.
9368    let sub_files: Vec<_> = run
9369        .per_file_records
9370        .iter()
9371        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9372        .collect();
9373    let test_count: u64 = sub_files
9374        .iter()
9375        .map(|r| r.raw_line_categories.test_count)
9376        .sum();
9377    #[allow(clippy::cast_precision_loss)]
9378    let coverage_line_pct: Option<f64> = {
9379        let found: u64 = sub_files
9380            .iter()
9381            .filter_map(|r| r.coverage.as_ref())
9382            .map(|c| u64::from(c.lines_found))
9383            .sum();
9384        let hit: u64 = sub_files
9385            .iter()
9386            .filter_map(|r| r.coverage.as_ref())
9387            .map(|c| u64::from(c.lines_hit))
9388            .sum();
9389        if found > 0 {
9390            let pct = (hit as f64 / found as f64) * 100.0;
9391            Some((pct * 10.0).round() / 10.0)
9392        } else {
9393            None
9394        }
9395    };
9396
9397    Some(MetricsHistoryEntry {
9398        code_lines: sub.code_lines,
9399        comment_lines: sub.comment_lines,
9400        blank_lines: sub.blank_lines,
9401        physical_lines: sub.total_physical_lines,
9402        files_analyzed: sub.files_analyzed,
9403        files_skipped: 0,
9404        test_count,
9405        html_url: sub_html_url,
9406        has_pdf: false,
9407        submodule_links: vec![],
9408        coverage_line_pct,
9409        ..base
9410    })
9411}
9412
9413#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
9414async fn api_metrics_history_handler(
9415    State(state): State<AppState>,
9416    Query(query): Query<MetricsHistoryQuery>,
9417) -> Response {
9418    let limit = query.limit.unwrap_or(50).min(500);
9419    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9420
9421    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9422        let reg = state.registry.lock().await;
9423        reg.entries
9424            .iter()
9425            .filter(|e| {
9426                query.root.as_ref().is_none_or(|root| {
9427                    let resolved = resolve_input_path(root);
9428                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9429                    e.input_roots.iter().any(|r| r == &root_str)
9430                })
9431            })
9432            .take(limit)
9433            .cloned()
9434            .collect()
9435    };
9436
9437    let entries: Vec<MetricsHistoryEntry> = candidate_entries
9438        .into_iter()
9439        .filter_map(|e| {
9440            let tags = e
9441                .git_tags
9442                .as_deref()
9443                .map(|s| {
9444                    s.split(',')
9445                        .map(|t| t.trim().to_string())
9446                        .filter(|t| !t.is_empty())
9447                        .collect()
9448                })
9449                .unwrap_or_default();
9450            let html_url = e
9451                .html_path
9452                .as_ref()
9453                .filter(|p| p.exists())
9454                .map(|_| format!("/runs/html/{}", e.run_id));
9455            let nearest_tag = e.git_nearest_tag.clone();
9456            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9457            let run_id_short: String = e
9458                .run_id
9459                .split('-')
9460                .next_back()
9461                .unwrap_or(&e.run_id)
9462                .chars()
9463                .take(7)
9464                .collect();
9465            let submodule_links = build_entry_submodule_links(&e);
9466            #[allow(clippy::cast_precision_loss)]
9467            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9468                let pct = (e.summary.coverage_lines_hit as f64
9469                    / e.summary.coverage_lines_found as f64)
9470                    * 100.0;
9471                Some((pct * 10.0).round() / 10.0)
9472            } else {
9473                None
9474            };
9475            let base = MetricsHistoryEntry {
9476                run_id: e.run_id.clone(),
9477                run_id_short,
9478                timestamp: e.timestamp_utc.to_rfc3339(),
9479                commit: e.git_commit.clone(),
9480                branch: e.git_branch.clone(),
9481                tags,
9482                nearest_tag,
9483                code_lines: e.summary.code_lines,
9484                comment_lines: e.summary.comment_lines,
9485                blank_lines: e.summary.blank_lines,
9486                physical_lines: e.summary.total_physical_lines,
9487                files_analyzed: e.summary.files_analyzed,
9488                files_skipped: e.summary.files_skipped,
9489                test_count: e.summary.test_count,
9490                project_label: e.project_label.clone(),
9491                html_url,
9492                has_pdf,
9493                submodule_links,
9494                coverage_line_pct,
9495            };
9496            if let Some(ref filter) = submodule_filter {
9497                apply_submodule_filter(base, filter, &e)
9498            } else {
9499                Some(base)
9500            }
9501        })
9502        .collect();
9503
9504    Json(entries).into_response()
9505}
9506
9507/// One scan's code churn versus the previous scan of the same project.
9508#[derive(Serialize)]
9509struct ChurnEntry {
9510    run_id: String,
9511    added: i64,
9512    removed: i64,
9513    modified: i64,
9514    unmodified: i64,
9515}
9516
9517// GET /api/metrics/churn?root=<path>&limit=<n>
9518// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
9519// comparing each scan to the previous scan of the same project. Loads per-file JSON
9520// artifacts, so it is intended for export-time use rather than every page load.
9521async fn api_metrics_churn_handler(
9522    State(state): State<AppState>,
9523    Query(query): Query<MetricsHistoryQuery>,
9524) -> Response {
9525    let limit = query.limit.unwrap_or(200).min(500);
9526    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9527        let reg = state.registry.lock().await;
9528        reg.entries
9529            .iter()
9530            .filter(|e| {
9531                query.root.as_ref().is_none_or(|root| {
9532                    let resolved = resolve_input_path(root);
9533                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9534                    e.input_roots.iter().any(|r| r == &root_str)
9535                })
9536            })
9537            .take(limit)
9538            .cloned()
9539            .collect()
9540    };
9541    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9542        std::collections::HashMap::new();
9543    for e in candidate_entries {
9544        by_project
9545            .entry(e.project_label.clone())
9546            .or_default()
9547            .push(e);
9548    }
9549    let mut out: Vec<ChurnEntry> = Vec::new();
9550    for (_proj, mut entries) in by_project {
9551        entries.sort_by_key(|e| e.timestamp_utc);
9552        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9553        for e in &entries {
9554            let curr = e
9555                .json_path
9556                .as_ref()
9557                .and_then(|path| sloc_core::read_json(path).ok());
9558            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9559                let cmp = sloc_core::compute_delta(prev, cur);
9560                out.push(ChurnEntry {
9561                    run_id: e.run_id.clone(),
9562                    added: sum_added_code_lines(&cmp),
9563                    removed: sum_removed_code_lines(&cmp),
9564                    modified: sum_modified_code_lines(&cmp),
9565                    unmodified: sum_unmodified_code_lines(&cmp),
9566                });
9567            } else {
9568                out.push(ChurnEntry {
9569                    run_id: e.run_id.clone(),
9570                    added: 0,
9571                    removed: 0,
9572                    modified: 0,
9573                    unmodified: 0,
9574                });
9575            }
9576            if curr.is_some() {
9577                prev_run = curr;
9578            }
9579        }
9580    }
9581    Json(out).into_response()
9582}
9583
9584// GET /api/metrics/submodules?root=<path>
9585// Returns the union of distinct submodule names found across all saved scan JSON artifacts
9586// for the given project root (or all roots if omitted).
9587#[derive(Deserialize)]
9588struct MetricsSubmodulesQuery {
9589    root: Option<String>,
9590}
9591
9592#[derive(Serialize)]
9593struct SubmoduleEntry {
9594    name: String,
9595    relative_path: String,
9596}
9597
9598async fn api_metrics_submodules_handler(
9599    State(state): State<AppState>,
9600    Query(query): Query<MetricsSubmodulesQuery>,
9601) -> Response {
9602    let json_paths: Vec<std::path::PathBuf> = {
9603        let reg = state.registry.lock().await;
9604        reg.entries
9605            .iter()
9606            .filter(|e| {
9607                query.root.as_ref().is_none_or(|root| {
9608                    let resolved = resolve_input_path(root);
9609                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9610                    e.input_roots.iter().any(|r| r == &root_str)
9611                })
9612            })
9613            .filter_map(|e| e.json_path.clone())
9614            .collect()
9615    };
9616
9617    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9618    let mut result: Vec<SubmoduleEntry> = Vec::new();
9619
9620    for path in &json_paths {
9621        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9622            continue;
9623        };
9624        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9625            continue;
9626        };
9627        for sub in &run.submodule_summaries {
9628            if seen.insert(sub.name.clone()) {
9629                result.push(SubmoduleEntry {
9630                    name: sub.name.clone(),
9631                    relative_path: sub.relative_path.clone(),
9632                });
9633            }
9634        }
9635    }
9636
9637    result.sort_by(|a, b| a.name.cmp(&b.name));
9638    Json(result).into_response()
9639}
9640
9641// ── CI ingest endpoint ────────────────────────────────────────────────────────
9642// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
9643// server stores and displays results without cloning or scanning anything itself.
9644//
9645// POST /api/ingest?label=<optional_display_name>
9646// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
9647// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
9648
9649#[derive(Deserialize)]
9650struct IngestQuery {
9651    label: Option<String>,
9652}
9653
9654#[derive(Serialize)]
9655struct IngestResponse {
9656    run_id: String,
9657    view_url: String,
9658}
9659
9660async fn api_ingest_handler(
9661    State(state): State<AppState>,
9662    Query(q): Query<IngestQuery>,
9663    Json(run): Json<sloc_core::AnalysisRun>,
9664) -> Response {
9665    let label = q.label.unwrap_or_else(|| {
9666        run.input_roots
9667            .first()
9668            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9669    });
9670
9671    let label_for_task = label.clone();
9672    let result = tokio::task::spawn_blocking(move || {
9673        let html = render_html(&run)?;
9674        let run_id = run.tool.run_id.clone();
9675        let run_id_safe = run_id.len() <= 128
9676            && !run_id.is_empty()
9677            && run_id
9678                .chars()
9679                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9680        if !run_id_safe {
9681            anyhow::bail!(
9682                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9683            );
9684        }
9685        let project_label = sanitize_project_label(&label_for_task);
9686        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9687        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9688            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9689            _ => project_label,
9690        };
9691        let (artifacts, _pending_pdf) = persist_run_artifacts(
9692            &run,
9693            &html,
9694            &output_dir,
9695            &label_for_task,
9696            &file_stem,
9697            RunResultContext::default(),
9698        )?;
9699        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9700    })
9701    .await;
9702
9703    match result {
9704        Ok(Ok((run_id, artifacts, run))) => {
9705            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9706            (
9707                StatusCode::CREATED,
9708                Json(IngestResponse {
9709                    view_url: format!("/view-reports?run_id={run_id}"),
9710                    run_id,
9711                }),
9712            )
9713                .into_response()
9714        }
9715        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9716        Err(e) => error::internal(&format!("{e}")),
9717    }
9718}
9719
9720// ── Multi-compare page ────────────────────────────────────────────────────────
9721// GET /multi-compare?runs=id1,id2,id3,...
9722
9723fn html_escape(s: &str) -> String {
9724    s.replace('&', "&amp;")
9725        .replace('<', "&lt;")
9726        .replace('>', "&gt;")
9727        .replace('"', "&quot;")
9728}
9729
9730#[allow(clippy::cast_precision_loss)]
9731fn fmt_num(n: i64) -> String {
9732    let a = n.unsigned_abs();
9733    if a >= 1_000_000 {
9734        let v = n as f64 / 1_000_000.0;
9735        let s = format!("{v:.1}");
9736        format!("{}M", s.trim_end_matches(".0"))
9737    } else if a >= 10_000 {
9738        let v = n as f64 / 1_000.0;
9739        let s = format!("{v:.1}");
9740        format!("{}K", s.trim_end_matches(".0"))
9741    } else {
9742        let sign = if n < 0 { "-" } else { "" };
9743        if a < 1_000 {
9744            return format!("{sign}{a}");
9745        }
9746        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9747    }
9748}
9749
9750fn fmt_comma(n: i64) -> String {
9751    let sign = if n < 0 { "-" } else { "" };
9752    let a = n.unsigned_abs();
9753    if a < 1_000 {
9754        return format!("{sign}{a}");
9755    }
9756    let s = a.to_string();
9757    let bytes = s.as_bytes();
9758    let len = bytes.len();
9759    let mut out = String::with_capacity(len + len / 3);
9760    for (i, &b) in bytes.iter().enumerate() {
9761        if i > 0 && (len - i).is_multiple_of(3) {
9762            out.push(',');
9763        }
9764        out.push(b as char);
9765    }
9766    format!("{sign}{out}")
9767}
9768
9769/// Insert thousands separators into the integer portion of a number's textual form.
9770///
9771/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9772/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9773/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9774/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9775fn group_thousands(s: &str) -> String {
9776    let (sign, rest) = match s.as_bytes().first() {
9777        Some(b'-') => ("-", &s[1..]),
9778        Some(b'+') => ("+", &s[1..]),
9779        _ => ("", s),
9780    };
9781    let (int_part, frac_part) = match rest.split_once('.') {
9782        Some((i, f)) => (i, Some(f)),
9783        None => (rest, None),
9784    };
9785    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9786        return s.to_string();
9787    }
9788    let bytes = int_part.as_bytes();
9789    let len = bytes.len();
9790    let mut grouped = String::with_capacity(len + len / 3);
9791    for (i, &b) in bytes.iter().enumerate() {
9792        if i > 0 && (len - i).is_multiple_of(3) {
9793            grouped.push(',');
9794        }
9795        grouped.push(b as char);
9796    }
9797    frac_part.map_or_else(
9798        || format!("{sign}{grouped}"),
9799        |f| format!("{sign}{grouped}.{f}"),
9800    )
9801}
9802
9803/// Custom Askama filters available to templates in this crate.
9804mod filters {
9805    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9806    // (a `&self` `execute` method returning `Result`), not on our own source.
9807    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9808    use askama::{Result, Values};
9809
9810    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9811    ///
9812    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9813    /// (dashes, "No prior scan", etc.) passes through untouched.
9814    #[askama::filter_fn]
9815    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9816        Ok(super::group_thousands(&value.to_string()))
9817    }
9818}
9819
9820#[derive(Deserialize, Default)]
9821struct MultiCompareQuery {
9822    runs: Option<String>,
9823    /// "super" to show only super-repo files (exclude all submodule files)
9824    scope: Option<String>,
9825    /// Submodule name to narrow the comparison to one submodule
9826    sub: Option<String>,
9827}
9828
9829#[allow(clippy::too_many_lines)]
9830async fn multi_compare_handler(
9831    State(state): State<AppState>,
9832    Query(params): Query<MultiCompareQuery>,
9833    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9834) -> impl IntoResponse {
9835    let run_ids: Vec<String> = params
9836        .runs
9837        .as_deref()
9838        .unwrap_or("")
9839        .split(',')
9840        .map(|s| s.trim().to_string())
9841        .filter(|s| !s.is_empty())
9842        .collect();
9843
9844    if run_ids.len() < 2 {
9845        return Html(
9846            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9847             <a href=\"/compare-scans\">Go back</a></p>",
9848        )
9849        .into_response();
9850    }
9851    if run_ids.len() > 20 {
9852        return Html(
9853            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9854             at once. <a href=\"/compare-scans\">Go back</a></p>",
9855        )
9856        .into_response();
9857    }
9858
9859    // Look up each run_id in the registry.
9860    let entries: Vec<Option<RegistryEntry>> = {
9861        let reg = state.registry.lock().await;
9862        run_ids
9863            .iter()
9864            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9865            .collect()
9866    };
9867
9868    for (i, entry) in entries.iter().enumerate() {
9869        if entry.is_none() {
9870            let html = format!(
9871                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9872                 found. <a href=\"/compare-scans\">Go back</a></p>",
9873                run_ids[i]
9874            );
9875            return Html(html).into_response();
9876        }
9877    }
9878
9879    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9880
9881    for entry in &entries {
9882        if entry.json_path.is_none() {
9883            let html = format!(
9884                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9885                 JSON data — re-run the analysis to enable comparison. \
9886                 <a href=\"/compare-scans\">Go back</a></p>",
9887                entry.run_id
9888            );
9889            return Html(html).into_response();
9890        }
9891    }
9892
9893    // Sort chronologically.
9894    entries.sort_by_key(|e| e.timestamp_utc);
9895
9896    // Load JSON for each entry.
9897    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9898    for entry in &entries {
9899        let path = entry.json_path.as_ref().unwrap();
9900        match read_json(path) {
9901            Ok(r) => runs.push(r),
9902            Err(e) => {
9903                let html = format!(
9904                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9905                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9906                    entry.run_id
9907                );
9908                return Html(html).into_response();
9909            }
9910        }
9911    }
9912
9913    // Collect submodule names from all runs.
9914    let all_sub_names: Vec<String> = {
9915        let mut set = std::collections::BTreeSet::new();
9916        for r in &runs {
9917            for s in &r.submodule_summaries {
9918                set.insert(s.name.clone());
9919            }
9920        }
9921        set.into_iter().collect()
9922    };
9923    let has_submodule_data = !all_sub_names.is_empty();
9924    let active_submodule = params.sub.clone();
9925    let super_scope_active = params.scope.as_deref() == Some("super");
9926
9927    // Narrow per_file_records when a scope is active, then recompute totals.
9928    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9929
9930    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9931    let project_label = entries
9932        .first()
9933        .map_or("", |e| e.project_label.as_str())
9934        .to_string();
9935    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9936    let multi = compute_multi_delta(&run_refs);
9937    let html = multi_compare_page(
9938        &multi,
9939        &project_label,
9940        env!("CARGO_PKG_VERSION"),
9941        &csp_nonce,
9942        has_submodule_data,
9943        &all_sub_names,
9944        &runs_csv,
9945        super_scope_active,
9946        active_submodule.as_deref(),
9947        &entries,
9948    );
9949    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9950    // copy after a rebuild would silently mask UI fixes.
9951    (
9952        [(axum::http::header::CACHE_CONTROL, "no-store")],
9953        Html(html),
9954    )
9955        .into_response()
9956}
9957
9958const fn multi_delta_class(n: i64) -> &'static str {
9959    match n {
9960        1.. => "pos",
9961        ..=-1 => "neg",
9962        0 => "zero",
9963    }
9964}
9965
9966fn multi_fmt_delta(n: i64) -> String {
9967    if n > 0 {
9968        format!("+{n}")
9969    } else {
9970        format!("{n}")
9971    }
9972}
9973
9974/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9975fn js_escape(s: &str) -> String {
9976    use std::fmt::Write as _;
9977    let mut out = String::with_capacity(s.len() + 2);
9978    for c in s.chars() {
9979        match c {
9980            '"' => out.push_str("\\\""),
9981            '\\' => out.push_str("\\\\"),
9982            '\n' => out.push_str("\\n"),
9983            '\r' => out.push_str("\\r"),
9984            '\t' => out.push_str("\\t"),
9985            c if (c as u32) < 0x20 => {
9986                let _ = write!(out, "\\u{:04x}", c as u32);
9987            }
9988            c => out.push(c),
9989        }
9990    }
9991    out
9992}
9993
9994/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9995fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9996    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9997        return (
9998            "&mdash;".to_string(),
9999            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
10000        );
10001    };
10002    let cd = entry
10003        .git_commit_date
10004        .as_deref()
10005        .and_then(fmt_git_date)
10006        .unwrap_or_else(|| "&mdash;".to_string());
10007    let au = entry.git_author.as_deref().map_or_else(
10008        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
10009        |a| {
10010            format!(
10011                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
10012                 <span class=\"cmp-author-handle\"></span></span>",
10013                html_escape(a)
10014            )
10015        },
10016    );
10017    (cd, au)
10018}
10019
10020/// Render the scope badge chip for a scan card header.
10021fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
10022    active_sub.map_or_else(
10023        || {
10024            if super_scope_active {
10025                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
10026            } else {
10027                "<span class=\"mc-scope-tag mc-scope-full\">\
10028                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
10029                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
10030                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
10031                 <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>\
10032                 </svg> Full scan</span>"
10033                    .to_string()
10034            }
10035        },
10036        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
10037    )
10038}
10039
10040/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
10041fn build_mc_scan_strip(
10042    multi: &MultiScanComparison,
10043    entries: &[RegistryEntry],
10044    n: usize,
10045    is_many: bool,
10046    active_sub: Option<&str>,
10047    super_scope_active: bool,
10048    project_label: &str,
10049) -> String {
10050    use std::fmt::Write as _;
10051    let mut scan_strip = String::new();
10052    for (i, pt) in multi.points.iter().enumerate() {
10053        let ts_ms = pt.timestamp.timestamp_millis();
10054        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10055        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
10056        let branch = pt.git_branch.as_deref().unwrap_or("");
10057        let report_link = format!("/runs/html/{}", pt.run_id);
10058        let branch_html = if branch.is_empty() {
10059            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
10060        } else {
10061            format!(
10062                "<span class=\"mc-card-branch\">{}</span>",
10063                html_escape(branch)
10064            )
10065        };
10066        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
10067        let tags_html = pt
10068            .git_tags
10069            .as_deref()
10070            .filter(|t| !t.is_empty())
10071            .map(|t| {
10072                let chips = t
10073                    .split(',')
10074                    .filter(|s| !s.is_empty())
10075                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
10076                    .collect::<Vec<_>>()
10077                    .join(" ");
10078                format!(
10079                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
10080                     <span class=\"mc-row-val\">{chips}</span></div>"
10081                )
10082            })
10083            .unwrap_or_default();
10084        let nearest = pt
10085            .git_nearest_tag
10086            .as_deref()
10087            .map(|t| format!("near {}", html_escape(t)))
10088            .unwrap_or_default();
10089        let arrow = if i < n - 1 && !is_many {
10090            "<div class='mc-arrow'>&#8594;</div>"
10091        } else {
10092            ""
10093        };
10094        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
10095        let nearest_html = if nearest.is_empty() {
10096            String::new()
10097        } else {
10098            format!(
10099                "<span class=\"mc-card-nearest-wrap\">\
10100                 <span class=\"mc-card-nearest\">{nearest}</span>\
10101                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
10102                 </span>"
10103            )
10104        };
10105        write!(
10106            scan_strip,
10107            r#"<div class="mc-card">
10108              <div class="mc-card-header">
10109                <div class="mc-card-num">Scan {num}</div>
10110                <div class="mc-card-project-col">
10111                  <div class="mc-card-project">{project_label}</div>
10112                  {scope_badge}
10113                </div>
10114              </div>
10115              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
10116              <div class="mc-card-rows">
10117                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
10118                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
10119                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
10120                <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>
10121                {tags_html}
10122              </div>
10123              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
10124            </div>{arrow}"#,
10125            num = i + 1,
10126            commit = html_escape(commit),
10127            commit_date = commit_date_html,
10128            ts_ms = ts_ms,
10129            code = fmt_num(pt.code_lines),
10130            scope_badge = scope_badge,
10131            nearest_html = nearest_html,
10132        )
10133        .unwrap();
10134    }
10135    scan_strip
10136}
10137
10138/// Build the metric progression table (thead + tbody) for multi-compare.
10139#[allow(clippy::too_many_lines)]
10140fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
10141    use std::fmt::Write as _;
10142    struct MetricRow<'a> {
10143        label: &'a str,
10144        values: Vec<i64>,
10145        seq_deltas: Vec<i64>,
10146        net_delta: i64,
10147    }
10148    let rows: Vec<MetricRow<'_>> = vec![
10149        MetricRow {
10150            label: "Code Lines",
10151            values: multi.points.iter().map(|p| p.code_lines).collect(),
10152            seq_deltas: multi
10153                .sequential_deltas
10154                .iter()
10155                .map(|d| d.summary.code_lines_delta)
10156                .collect(),
10157            net_delta: multi.total_delta.code_lines_delta,
10158        },
10159        MetricRow {
10160            label: "Files Analyzed",
10161            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
10162            seq_deltas: multi
10163                .sequential_deltas
10164                .iter()
10165                .map(|d| d.summary.files_analyzed_delta)
10166                .collect(),
10167            net_delta: multi.total_delta.files_analyzed_delta,
10168        },
10169        MetricRow {
10170            label: "Comment Lines",
10171            values: multi.points.iter().map(|p| p.comment_lines).collect(),
10172            seq_deltas: multi
10173                .sequential_deltas
10174                .iter()
10175                .map(|d| d.summary.comment_lines_delta)
10176                .collect(),
10177            net_delta: multi.total_delta.comment_lines_delta,
10178        },
10179        MetricRow {
10180            label: "Blank Lines",
10181            values: multi.points.iter().map(|p| p.blank_lines).collect(),
10182            seq_deltas: multi
10183                .sequential_deltas
10184                .iter()
10185                .map(|d| d.summary.blank_lines_delta)
10186                .collect(),
10187            net_delta: multi.total_delta.blank_lines_delta,
10188        },
10189        MetricRow {
10190            label: "Tests",
10191            values: multi.points.iter().map(|p| p.test_count).collect(),
10192            seq_deltas: multi
10193                .points
10194                .windows(2)
10195                .map(|pts| pts[1].test_count - pts[0].test_count)
10196                .collect(),
10197            net_delta: multi.points.last().map_or(0, |l| l.test_count)
10198                - multi.points.first().map_or(0, |f| f.test_count),
10199        },
10200    ];
10201    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
10202    for i in 0..n {
10203        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
10204        if i < n - 1 {
10205            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
10206        }
10207    }
10208    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
10209    let mut metrics_tbody = String::new();
10210    for row in &rows {
10211        metrics_tbody.push_str("<tr>");
10212        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
10213        for i in 0..n {
10214            write!(
10215                metrics_tbody,
10216                "<td class='mc-val-col'>{}</td>",
10217                fmt_comma(row.values[i])
10218            )
10219            .unwrap();
10220            if i < n - 1 {
10221                let d = row.seq_deltas[i];
10222                write!(
10223                    metrics_tbody,
10224                    "<td class='mc-delta-col {cls}'>{val}</td>",
10225                    cls = multi_delta_class(d),
10226                    val = multi_fmt_delta(d)
10227                )
10228                .unwrap();
10229            }
10230        }
10231        let nd = row.net_delta;
10232        write!(
10233            metrics_tbody,
10234            "<td class='mc-net-col {cls}'>{val}</td>",
10235            cls = multi_delta_class(nd),
10236            val = multi_fmt_delta(nd)
10237        )
10238        .unwrap();
10239        metrics_tbody.push_str("</tr>");
10240    }
10241    (metrics_thead, metrics_tbody)
10242}
10243
10244/// Build the JS-embeddable points JSON array for the multi-compare chart.
10245fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10246    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10247    for (i, pt) in multi.points.iter().enumerate() {
10248        let commit = pt.git_commit.as_deref().unwrap_or("");
10249        let branch = pt.git_branch.as_deref().unwrap_or("");
10250        let tags = pt.git_tags.as_deref().unwrap_or("");
10251        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10252        let scanned_ms = pt.timestamp.timestamp_millis();
10253        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10254        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10255        let commit_date = entry
10256            .and_then(|e| e.git_commit_date.as_deref())
10257            .and_then(fmt_git_date)
10258            .unwrap_or_default();
10259        let author = entry
10260            .and_then(|e| e.git_author.as_deref())
10261            .unwrap_or("")
10262            .to_string();
10263        let cov = pt
10264            .coverage_line_pct
10265            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10266        parts.push(format!(
10267            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}}}"#,
10268            run_id = js_escape(&pt.run_id),
10269            commit = js_escape(commit),
10270            branch = js_escape(branch),
10271            tags = js_escape(tags),
10272            nearest = js_escape(nearest),
10273            commit_date = js_escape(&commit_date),
10274            author = js_escape(&author),
10275            scanned = js_escape(&scanned),
10276            code = pt.code_lines,
10277            comments = pt.comment_lines,
10278            blank = pt.blank_lines,
10279            files = pt.files_analyzed,
10280            tests = pt.test_count,
10281        ));
10282    }
10283    format!("[{}]", parts.join(","))
10284}
10285
10286/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
10287fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10288    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10289    for row in &multi.file_matrix {
10290        let lang = row.language.as_deref().unwrap_or("");
10291        let codes: Vec<String> = row
10292            .code_per_scan
10293            .iter()
10294            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10295            .collect();
10296        let deltas: Vec<String> = row
10297            .code_delta_per_scan
10298            .iter()
10299            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10300            .collect();
10301        parts.push(format!(
10302            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10303            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10304            status = row.overall_status,
10305            codes = codes.join(","),
10306            deltas = deltas.join(","),
10307            total = row.total_code_delta,
10308        ));
10309    }
10310    format!("[{}]", parts.join(","))
10311}
10312
10313/// Build the column header cells for the file-matrix table.
10314fn build_mc_file_col_headers(n: usize) -> String {
10315    use std::fmt::Write as _;
10316    let mut out = String::new();
10317    for i in 0..n {
10318        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10319        if i < n - 1 {
10320            write!(
10321                out,
10322                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
10323                i + 2
10324            )
10325            .unwrap();
10326        }
10327    }
10328    out
10329}
10330
10331/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
10332fn build_mc_scope_bar(
10333    has_submodule_data: bool,
10334    sub_names: &[String],
10335    runs_csv: &str,
10336    active_sub: Option<&str>,
10337    super_scope_active: bool,
10338) -> String {
10339    use std::fmt::Write as _;
10340    if !has_submodule_data {
10341        return String::new();
10342    }
10343    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10344    let full_active = active_sub.is_none() && !super_scope_active;
10345    let mut bar = format!(
10346        r#"<div class="submod-scope-bar">
10347  <span class="submod-scope-label">
10348    <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>
10349    Scope:
10350  </span>
10351  <div class="submod-scope-divider"></div>
10352  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10353  <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>"#,
10354        full_cls = if full_active { " active" } else { "" },
10355        super_cls = if super_scope_active { " active" } else { "" },
10356    );
10357    for s in sub_names {
10358        let is_active = active_sub == Some(s.as_str());
10359        write!(
10360            bar,
10361            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10362            cls = if is_active { " active" } else { "" },
10363            name_enc = html_escape(s),
10364            name_esc = html_escape(s),
10365        )
10366        .unwrap();
10367    }
10368    bar.push_str("\n</div>");
10369    bar
10370}
10371
10372/// Build the scope-description label shown in the page subtitle.
10373fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10374    active_sub.map_or_else(
10375        || {
10376            if super_scope_active {
10377                "Super-repo only &mdash; ".to_string()
10378            } else {
10379                String::new()
10380            }
10381        },
10382        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
10383    )
10384}
10385
10386#[allow(clippy::too_many_lines)]
10387#[allow(clippy::too_many_arguments)]
10388fn multi_compare_page(
10389    multi: &MultiScanComparison,
10390    project_label: &str,
10391    version: &str,
10392    csp_nonce: &str,
10393    has_submodule_data: bool,
10394    sub_names: &[String],
10395    runs_csv: &str,
10396    super_scope_active: bool,
10397    active_sub: Option<&str>,
10398    entries: &[RegistryEntry],
10399) -> String {
10400    let n = multi.points.len();
10401    let is_many = n > 4;
10402    let mc_strip_class = if is_many {
10403        "mc-strip mc-strip-grid"
10404    } else {
10405        "mc-strip"
10406    };
10407
10408    // ── Scan strip cards ──────────────────────────────────────────────────────
10409    let scan_strip = build_mc_scan_strip(
10410        multi,
10411        entries,
10412        n,
10413        is_many,
10414        active_sub,
10415        super_scope_active,
10416        project_label,
10417    );
10418
10419    // ── Summary metrics table ─────────────────────────────────────────────────
10420    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10421
10422    // ── Chart data and table helpers ──────────────────────────────────────────
10423    let points_json = build_mc_points_json(multi, entries);
10424    let file_matrix_json = build_mc_file_matrix_json(multi);
10425
10426    // Counts for filter tabs
10427    let files_modified = multi
10428        .file_matrix
10429        .iter()
10430        .filter(|f| f.overall_status == "modified")
10431        .count();
10432    let files_added = multi
10433        .file_matrix
10434        .iter()
10435        .filter(|f| f.overall_status == "added")
10436        .count();
10437    let files_removed = multi
10438        .file_matrix
10439        .iter()
10440        .filter(|f| f.overall_status == "removed")
10441        .count();
10442    let files_unchanged = multi
10443        .file_matrix
10444        .iter()
10445        .filter(|f| f.overall_status == "unchanged")
10446        .count();
10447    let total_files = multi.file_matrix.len();
10448
10449    let file_col_headers = build_mc_file_col_headers(n);
10450    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10451    let scope_bar_html = build_mc_scope_bar(
10452        has_submodule_data,
10453        sub_names,
10454        runs_csv,
10455        active_sub,
10456        super_scope_active,
10457    );
10458    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10459    let toast_assets = sloc_toast_assets(csp_nonce);
10460
10461    format!(
10462        r#"<!doctype html>
10463<html lang="en">
10464<head>
10465  <meta charset="utf-8">
10466  <meta name="viewport" content="width=device-width, initial-scale=1">
10467  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10468  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10469  <style nonce="{csp_nonce}">
10470    :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;}}
10471    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10472    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10473    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;}}
10474    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10475    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10476    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10477    .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;}}
10478    @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));}}}}
10479    .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);}}
10480    .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;}}
10481    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10482    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10483    @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;}}}}
10484    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10485    .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));}}
10486    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10487    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10488    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10489    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10490    .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;}}
10491    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10492    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10493    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10494    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10495    .nav-dropdown{{position:relative;display:inline-flex;}}
10496    .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;}}
10497    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10498    .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;}}
10499    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10500    .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);}}
10501    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10502    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10503    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10504    body:not(.dark-theme) .icon-sun{{display:none;}}
10505    body.dark-theme .icon-moon{{display:none;}}
10506    .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);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;}}
10507    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10508    .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);}}
10509    .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;}}
10510    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10511    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10512    .settings-modal-body{{padding:14px 16px 16px;}}
10513    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10514    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10515    .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;}}
10516    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10517    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10518    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10519    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10520    .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;}}
10521    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10522    .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;}}
10523    .btn-back:hover{{background:var(--line);}}
10524    .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;}}
10525    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;}}
10526    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10527    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10528    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10529    .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;}}
10530    .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;}}
10531    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10532    .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;}}
10533    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10534    body.dark-theme .mc-card{{background:var(--surface-2);}}
10535    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10536    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10537    .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%;}}
10538    .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;}}
10539    .mc-card-commit:hover{{color:var(--oxide);}}
10540    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10541    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10542    .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;}}
10543    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10544    .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;}}
10545    .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;}}
10546    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10547    .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;}}
10548    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10549    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10550    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10551    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10552    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10553    .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);}}
10554    .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);}}
10555    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10556    .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;}}
10557    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10558    .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;}}
10559    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10560    .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;}}
10561    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10562    .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;}}
10563    .submod-scope-btn:hover{{background:var(--line);}}
10564    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10565    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10566    .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;}}
10567    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10568    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10569    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10570    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10571    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10572    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10573    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10574    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10575    .metrics-table .pos{{color:var(--pos);}}
10576    .metrics-table .neg{{color:var(--neg);}}
10577    .metrics-table .zero{{color:var(--muted);}}
10578    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10579    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10580    .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;}}
10581    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10582    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10583    .chart-wrap{{width:100%;overflow-x:auto;}}
10584    #mc-chart{{display:block;width:100%;}}
10585    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10586    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10587    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10588    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10589    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10590    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10591    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10592    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10593    .ic-card-h2-row .ic-card-h2{{margin:0;}}
10594    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10595    .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;}}
10596    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10597    .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;}}
10598    .ic-svg-modal-ov.open{{display:flex;}}
10599    .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);}}
10600    .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);}}
10601    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10602    .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;}}
10603    .ic-svg-modal-close:hover{{background:var(--line);}}
10604    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10605    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10606    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10607    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10608    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10609    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10610    #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;}}
10611    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10612    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10613    .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;}}
10614    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10615    .tab-btn:hover:not(.active){{background:var(--line);}}
10616    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10617    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10618    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10619    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10620    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10621    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10622    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10623    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10624    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10625    .table-wrap{{width:100%;overflow-x:auto;}}
10626    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10627    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10628    #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;}}
10629    #file-table th.left,#file-table td.left{{text-align:left;}}
10630    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10631    .file-delta-col{{color:var(--muted);font-size:11px;}}
10632    .file-net-col{{font-weight:800;}}
10633    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10634    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10635    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10636    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10637    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10638    .status-badge.modified{{background:#fff2d8;color:#926000;}}
10639    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10640    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10641    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10642    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10643    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10644    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10645    tr.row-added td{{background:rgba(26,143,71,0.04);}}
10646    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10647    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10648    tr.row-unchanged td{{color:var(--muted);}}
10649    tr.row-unchanged .status-badge{{opacity:.65;}}
10650    .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;}}
10651    .absent{{color:var(--muted);font-style:italic;}}
10652    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10653    .pagination-info{{font-size:12px;color:var(--muted);}}
10654    .pagination-btns{{display:flex;gap:5px;}}
10655    .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;}}
10656    .pg-btn:hover:not(:disabled){{background:var(--line);}}
10657    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10658    .pg-btn:disabled{{opacity:.35;cursor:default;}}
10659    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;}}
10660    .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;}}
10661    .export-btn:hover{{background:var(--line);}}
10662    .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;}}
10663    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10664    .site-footer a{{color:var(--muted);}}
10665    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;}}
10666    body.pdf-mode{{background:#fff!important;}}
10667    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10668    .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;}}
10669    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10670    .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;}}
10671    .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;}}
10672    .mc-modal-title{{font-size:18px;font-weight:800;}}
10673    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10674    .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;}}
10675    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10676    .mc-modal-body{{padding:18px 22px;}}
10677    .mc-modal-sec{{margin-bottom:20px;}}
10678    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10679    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10680    .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;}}
10681    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10682    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10683    .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;}}
10684    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10685    .mc-modal-row:last-child{{border-bottom:none;}}
10686    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10687    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10688    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10689    .mc-modal-val a:hover{{text-decoration:underline;}}
10690    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10691    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10692    .mc-modal-stat[data-tip]{{cursor:help;}}
10693    #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);}}
10694    .mc-card{{cursor:pointer;}}
10695    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10696  </style>
10697</head>
10698<body>
10699  {loading_overlay}
10700  <div class="background-watermarks" aria-hidden="true">
10701    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10702    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10703    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10704    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10705    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10706    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10707  </div>
10708  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10709  <div class="top-nav">
10710    <div class="top-nav-inner">
10711      <a class="brand" href="/">
10712        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10713        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10714      </a>
10715      <div class="nav-right">
10716        <a class="nav-pill" href="/">Home</a>
10717        <div class="nav-dropdown">
10718          <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>
10719          <div class="nav-dropdown-menu">
10720            <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>
10721          </div>
10722        </div>
10723        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10724        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10725        <div class="nav-dropdown">
10726          <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>
10727          <div class="nav-dropdown-menu">
10728            <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>
10729          </div>
10730        </div>
10731        <div class="server-status-wrap" id="server-status-wrap">
10732          <div class="nav-pill server-online-pill" id="server-status-pill">
10733            <span class="status-dot" id="status-dot"></span>
10734            <span id="server-status-label">Server</span>
10735            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10736          </div>
10737          <div class="server-status-tip">
10738            OxideSLOC is running &mdash; accessible on your network.
10739            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10740          </div>
10741        </div>
10742        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10743          <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>
10744        </button>
10745        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10746          <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>
10747          <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>
10748        </button>
10749      </div>
10750    </div>
10751  </div>
10752
10753  <div class="page">
10754    <!-- Hero header -->
10755    <div class="mc-hero">
10756      <div class="mc-hero-header">
10757        <div>
10758          <div class="mc-title">Multi-Scan Timeline</div>
10759          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10760          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10761        </div>
10762        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10763          <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>
10764          <div class="export-group" id="mc-top-export-group">
10765            <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>
10766            <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>
10767          </div>
10768        </div>
10769      </div>
10770      {scope_bar_html}
10771      <!-- Scan strip -->
10772      <div class="{mc_strip_class}">{scan_strip}</div>
10773    </div>
10774
10775    <!-- Summary metrics table -->
10776    <div class="panel">
10777      <div class="panel-title">Metric Progression</div>
10778      <div class="table-wrap">
10779        <table class="metrics-table">
10780          <thead>{metrics_thead}</thead>
10781          <tbody>{metrics_tbody}</tbody>
10782        </table>
10783      </div>
10784    </div>
10785
10786    <!-- Scan Charts -->
10787    <div class="panel" id="mc-charts-panel">
10788      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10789      <div class="ic-grid">
10790        <!-- Timeline line chart — spans full width -->
10791        <div class="ic-card" style="grid-column:span 2">
10792          <div class="ic-card-h2-row">
10793            <span class="ic-card-h2">Timeline</span>
10794            <div class="chart-toolbar" style="margin:0">
10795              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10796              <button class="chart-metric-btn" data-metric="files">Files</button>
10797              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10798              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10799              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10800            </div>
10801          </div>
10802          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10803        </div>
10804        <!-- Code Metrics: Scan 1 vs Latest -->
10805        <div class="ic-card">
10806          <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>
10807          <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>
10808          <div id="mc-ic-c1"></div>
10809        </div>
10810        <!-- Language Code Delta -->
10811        <div class="ic-card" id="mc-ic-lang-card">
10812          <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>
10813          <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>
10814          <div id="mc-ic-c3"></div>
10815        </div>
10816        <!-- Delta by Metric -->
10817        <div class="ic-card">
10818          <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>
10819          <div id="mc-ic-c2"></div>
10820        </div>
10821        <!-- File Change Distribution -->
10822        <div class="ic-card">
10823          <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>
10824          <div id="mc-ic-c4"></div>
10825        </div>
10826      </div>
10827    </div>
10828
10829    <!-- File matrix table -->
10830    <div class="panel">
10831      <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>
10832      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10833        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10834          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10835          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10836          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10837          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10838          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10839        </div>
10840        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10841          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10842          <div class="export-group">
10843          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10844          <button type="button" class="export-btn" id="export-csv-btn">
10845            <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>
10846            CSV
10847          </button>
10848          <button type="button" class="export-btn" id="mc-file-xls-btn">
10849            <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>
10850            Excel
10851          </button>
10852          </div>
10853        </div>
10854      </div>
10855      <div class="table-wrap">
10856        <table id="file-table">
10857          <thead>
10858            <tr>
10859              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10860              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10861              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10862              {file_col_headers}
10863              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10864            </tr>
10865          </thead>
10866          <tbody id="file-tbody"></tbody>
10867        </table>
10868      </div>
10869      <div class="pagination">
10870        <span class="pagination-info" id="pg-info"></span>
10871        <div class="pagination-btns" id="pg-btns"></div>
10872        <div style="display:flex;align-items:center;gap:6px;">
10873          <span style="font-size:12px;color:var(--muted)">Show</span>
10874          <select class="per-page" id="per-page-sel">
10875            <option value="25" selected>25 per page</option>
10876            <option value="50">50 per page</option>
10877            <option value="100">100 per page</option>
10878          </select>
10879        </div>
10880      </div>
10881    </div>
10882  </div>
10883
10884  <div id="mc-ic-tt"></div>
10885
10886  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10887    <div class="ic-svg-modal">
10888      <div class="ic-svg-modal-hdr">
10889        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10890        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10891      </div>
10892      <div id="ic-svg-modal-body"></div>
10893    </div>
10894  </div>
10895
10896  <footer class="site-footer">
10897    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10898    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10899    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10900    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10901    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10902  </footer>
10903
10904  <script nonce="{csp_nonce}">
10905  (function(){{
10906    // ── Dark theme ───────────────────────────────────────────────────────────
10907    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10908    var renderInlineCharts=null;
10909    var tt=document.getElementById('theme-toggle');
10910    if(tt)tt.addEventListener('click',function(){{
10911      var on=document.body.classList.toggle('dark-theme');
10912      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10913      renderChart(activeMetric);
10914      if(renderInlineCharts)renderInlineCharts();
10915    }});
10916
10917    // ── Code particles ───────────────────────────────────────────────────────
10918    var container=document.getElementById('code-particles');
10919    if(container){{
10920      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()'];
10921      for(var i=0;i<28;i++){{
10922        (function(idx){{
10923          var el=document.createElement('span');el.className='code-particle';
10924          el.textContent=snips[idx%snips.length];
10925          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10926          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10927          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10928          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10929          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10930          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10931          container.appendChild(el);
10932        }})(i);
10933      }}
10934    }}
10935
10936    // ── Watermarks ───────────────────────────────────────────────────────────
10937    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10938    if(wms.length){{
10939      var placed=[];
10940      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;}}
10941      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];}}
10942      var half=Math.floor(wms.length/2);
10943      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;}});
10944    }}
10945
10946    // ── Settings / colour scheme modal ───────────────────────────────────────
10947    (function(){{
10948      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'}}];
10949      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);}});}}
10950      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10951      function init(){{
10952        var btn=document.getElementById('settings-btn');if(!btn)return;
10953        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10954        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>';
10955        document.body.appendChild(m);
10956        var g=document.getElementById('scheme-grid');
10957        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);}});
10958        var cl=document.getElementById('settings-close-btn');
10959        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');}});
10960        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10961        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10962      }}
10963      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10964    }})();
10965
10966    // ── Timezone support for scan timestamps ─────────────────────────────────
10967    (function(){{
10968      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.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};
10969      window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};
10970      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);}});}};
10971      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10972      window.applyTz(storedTz);
10973      function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;window.enhanceTzOptions(tzSel);tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});if(!document.getElementById('tf-select')&&tzSel.parentNode){{var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzSel.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}}}}
10974      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10975    }})();
10976
10977    // ── Data ────────────────────────────────────────────────────────────────
10978    var POINTS={points_json};
10979    var FILES={file_matrix_json};
10980    var N={n};
10981
10982    // ── fmt helper ───────────────────────────────────────────────────────────
10983    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();}}
10984    function fmtFull(n){{return Number(n).toLocaleString();}}
10985    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10986
10987    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10988    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10989    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));}}
10990    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10991    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10992
10993    // ── Timeline chart ───────────────────────────────────────────────────────
10994    var activeMetric='code';
10995    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10996    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10997
10998    function renderChart(metric){{
10999      var svg=document.getElementById('mc-chart');if(!svg)return;
11000      var W=svg.getBoundingClientRect().width||800,H=280;
11001      svg.setAttribute('height',H);
11002      var pad={{l:62,r:20,t:32,b:72}};
11003      var dark=document.body.classList.contains('dark-theme');
11004      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
11005      var valid=pts.filter(function(v){{return v!=null;}});
11006      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;}}
11007      var minV=0,maxV=Math.max.apply(null,valid);
11008      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
11009      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
11010      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
11011      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
11012      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
11013      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
11014      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
11015      var parts=[];
11016      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
11017      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>');}}
11018      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
11019      var lineD='';var firstPt=true;
11020      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);}}}}
11021      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
11022      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
11023      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
11024      for(var i=0;i<N;i++){{
11025        if(pts[i]==null)continue;
11026        var cx=xOf(i),cy=yOf(pts[i]);
11027        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
11028        var hasTag=p.tags&&p.tags.length>0;
11029        // Permanent Y-value label above the dot
11030        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>');
11031        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+'"/>');
11032        var xanchor=i===0?'start':i===N-1?'end':'middle';
11033        // X-axis label at 2× the original size (18 px)
11034        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>');
11035      }}
11036      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
11037      svg.setAttribute('viewBox','0 0 '+W+' '+H);
11038      svg.innerHTML=parts.join('');
11039      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');}});
11040      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
11041      svg.onmousemove=function(e){{
11042        var rect=svg.getBoundingClientRect();
11043        var scaleX=W/rect.width;
11044        var mouseX=(e.clientX-rect.left)*scaleX;
11045        var nearest=-1,minDist=Infinity;
11046        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;}}}}
11047        if(nearest<0)return;
11048        var nc=xOf(nearest),ny=yOf(pts[nearest]);
11049        var xhair=svg.querySelector('.mc-xhair');
11050        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
11051        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"/>';
11052        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
11053        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
11054        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>';
11055        var bx=rect.left+(nc/W*rect.width)+18;
11056        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
11057        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
11058      }};
11059      svg.onmouseleave=function(){{
11060        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
11061        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
11062      }};
11063    }}
11064
11065    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11066
11067    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
11068      btn.addEventListener('click',function(){{
11069        activeMetric=this.dataset.metric;
11070        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
11071        this.classList.add('active');
11072        renderChart(activeMetric);
11073      }});
11074    }});
11075    if(typeof ResizeObserver!=='undefined'){{
11076      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
11077    }}
11078    renderChart(activeMetric);
11079
11080    // ── File matrix table ────────────────────────────────────────────────────
11081    var activeStatus='';
11082    var currentPage=1;
11083    var perPage=25;
11084    var mcSortCol=null,mcSortAsc=true;
11085
11086    function getFiltered(){{
11087      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
11088      if(!mcSortCol)return data;
11089      var asc=mcSortAsc;
11090      return data.slice().sort(function(a,b){{
11091        var va,vb;
11092        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
11093        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
11094        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
11095        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
11096        else{{return 0;}}
11097        if(asc)return va<vb?-1:va>vb?1:0;
11098        return va<vb?1:va>vb?-1:0;
11099      }});
11100    }}
11101
11102    function renderFilePage(){{
11103      var filtered=getFiltered();
11104      var total=filtered.length;
11105      var totalPages=Math.max(1,Math.ceil(total/perPage));
11106      if(currentPage>totalPages)currentPage=totalPages;
11107      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
11108      var tbody=document.getElementById('file-tbody');if(!tbody)return;
11109      var rows=[];
11110      for(var i=start;i<end;i++){{
11111        var f=filtered[i];
11112        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
11113        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
11114        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
11115        for(var j=0;j<N;j++){{
11116          var cv=f.c[j];
11117          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
11118          if(j<N-1){{
11119            var dv=f.d[j+1];
11120            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
11121              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
11122          }}
11123        }}
11124        var tc=f.t;
11125        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
11126        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
11127      }}
11128      tbody.innerHTML=rows.join('');
11129
11130      var info=document.getElementById('pg-info');
11131      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
11132      renderPgBtns(totalPages);
11133    }}
11134
11135    function renderPgBtns(totalPages){{
11136      var wrap=document.getElementById('pg-btns');if(!wrap)return;
11137      var btns=[];
11138      function mkBtn(label,page,active,disabled){{
11139        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
11140        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
11141      }}
11142      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
11143      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
11144      if(s>1)btns.push(mkBtn('1',1,false,false));
11145      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
11146      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
11147      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
11148      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
11149      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
11150      wrap.innerHTML=btns.join('');
11151      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
11152        b.addEventListener('click',function(){{
11153          var pg=parseInt(this.dataset.pg,10);
11154          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
11155        }});
11156      }});
11157    }}
11158
11159    // Tab filter
11160    document.querySelectorAll('.tab-btn').forEach(function(btn){{
11161      btn.addEventListener('click',function(){{
11162        activeStatus=this.dataset.status||'';
11163        currentPage=1;
11164        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11165        this.classList.add('active');
11166        renderFilePage();
11167      }});
11168    }});
11169
11170    // Per-page selector
11171    var ppSel=document.getElementById('per-page-sel');
11172    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
11173
11174    // ── Column header sort ───────────────────────────────────────────────────
11175    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
11176      th.addEventListener('click',function(){{
11177        var col=th.dataset.sortCol;
11178        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
11179        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11180          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
11181        }});
11182        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
11183        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
11184        currentPage=1;renderFilePage();
11185      }});
11186    }});
11187
11188    // Reset button also clears sort
11189    var mcResetBtn=document.getElementById('mc-file-reset-btn');
11190    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
11191      mcSortCol=null;mcSortAsc=true;
11192      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11193        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
11194      }});
11195      activeStatus='';currentPage=1;
11196      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11197      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
11198      renderFilePage();
11199    }});
11200
11201    renderFilePage();
11202
11203    // ── CSV export ───────────────────────────────────────────────────────────
11204    var exportBtn=document.getElementById('export-csv-btn');
11205    if(exportBtn)exportBtn.addEventListener('click',function(){{
11206      var header=['File','Language','Status'];
11207      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
11208      header.push('Net Delta');
11209      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
11210      var filtered=getFiltered();
11211      filtered.forEach(function(f){{
11212        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
11213        for(var j=0;j<N;j++){{
11214          cols.push(f.c[j]!=null?f.c[j]:'');
11215          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
11216        }}
11217        cols.push(f.t);
11218        rows.push(cols.join(','));
11219      }});
11220      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
11221      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11222      a.download=mcExportName('csv');a.click();
11223    }});
11224
11225    // ── File matrix extra export buttons ─────────────────────────────────────
11226    (function(){{
11227      var resetBtn=document.getElementById('mc-file-reset-btn');
11228      if(resetBtn)resetBtn.addEventListener('click',function(){{
11229        activeStatus='';currentPage=1;
11230        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11231        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11232        renderFilePage();
11233      }});
11234
11235      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11236      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11237      function mcMakeXlsx(fname){{
11238        var filtered=getFiltered();
11239        var enc=new TextEncoder();
11240        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;}}
11241        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;}}
11242        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11243        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11244        var ss=[],si={{}};
11245        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11246        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11247        function WS(){{
11248          var R=0,buf=[];
11249          function cl(c){{return String.fromCharCode(65+c);}}
11250          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11251          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11252          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11253          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>';}}
11254          return{{sc:sc,nc:nc,row:row,xml:xml}};
11255        }}
11256        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11257        var proj=mcExportProj();
11258        // \u2500\u2500 Summary sheet \u2500\u2500
11259        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11260        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11261        r1(s1(0,proj,2));
11262        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11263        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
11264        r1('');
11265        r1(s1(0,'SCAN SUMMARY',8));
11266        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));
11267        POINTS.forEach(function(p,i){{
11268          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11269          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));
11270        }});
11271        r1('');
11272        if(POINTS.length>1){{
11273          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11274          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11275          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11276          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)));}};
11277          nr('Code Lines',pf.code,pl.code);
11278          nr('Comment Lines',pf.comments,pl.comments);
11279          nr('Files Analyzed',pf.files,pl.files);
11280          nr('Tests',pf.tests,pl.tests);
11281          r1('');
11282        }}
11283        var cMod=0,cAdd=0,cRem=0,cUnch=0;
11284        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11285        var totF=FILES.length||1;
11286        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11287        r1(s1(0,'FILE CHANGES',8));
11288        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11289        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11290        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11291        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11292        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11293        r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11294        var lm={{}};
11295        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;}});
11296        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11297        if(langs.length){{
11298          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11299          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11300          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)));}});
11301        }}
11302        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11303        // \u2500\u2500 File Delta sheet \u2500\u2500
11304        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11305        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11306        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);}}
11307        hcells+=s2(hc,'Net Delta',3);
11308        r2(hcells);
11309        filtered.forEach(function(f){{
11310          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11311          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));}}}}
11312          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11313          r2(cells);
11314        }});
11315        var ncols=3+N+(N-1)+1;
11316        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11317        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>';
11318        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11319        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>',
11320          '_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>',
11321          '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>',
11322          '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>',
11323          '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>',
11324          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11325        var zparts=[],zcds=[],zoff=0,znf=0;
11326        ['[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){{
11327          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11328          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]);
11329          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);
11330          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));
11331          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11332          zoff+=entry.length;znf++;
11333        }});
11334        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11335        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]);
11336        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11337        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11338        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11339        out.set(new Uint8Array(eocd),pos);
11340        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11341        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11342      }}
11343
11344      var xlsBtn=document.getElementById('mc-file-xls-btn');
11345      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11346
11347      // File matrix HTML export — interactive: sort by column, filter by status
11348      function mcFileBuildHtml(){{
11349        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11350        var hdrs=['File','Language','Status'];
11351        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11352        hdrs.push('Net \u0394');
11353        var SI=2;
11354        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;}});
11355        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11356        var cnt={{all:allRows.length}};
11357        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11358        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11359        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11360          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11361          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11362          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11363          '.sub{{font-size:12px;color:#99aabb;}}'+
11364          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11365          '.wr{{padding:16px 20px;}}'+
11366          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11367          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11368          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11369          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11370          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11371          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11372          'thead tr{{background:#1a2035;}}'+
11373          '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;}}'+
11374          'th:hover{{background:#2a3050;}}'+
11375          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11376          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11377          'tr:nth-child(even) td{{background:#faf7f4;}}'+
11378          'tr:hover td{{background:#f5f0ea;}}'+
11379          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11380          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11381        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11382        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11383          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11384          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11385          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11386          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11387        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11388          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
11389          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
11390          '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>";}}'+
11391          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11392          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11393          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
11394          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11395          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11396          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11397          '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("")'+
11398          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11399          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11400          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11401          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11402          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11403          'sd=(sc===ci)?-sd:1;sc=ci;'+
11404          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11405          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11406          'render();';
11407        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11408          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11409          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
11410          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11411          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11412          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11413          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11414          '<script>'+inlineJs+'<\/script><\/body><\/html>';
11415      }}
11416
11417      var htmlBtn=document.getElementById('mc-file-html-btn');
11418      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11419        var h=mcFileBuildHtml();
11420        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11421        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11422        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11423      }});
11424
11425      var pdfBtn=document.getElementById('mc-file-pdf-btn');
11426      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11427        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11428      }});
11429    }})();
11430
11431    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11432    (function(){{
11433      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11434      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11435      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11436      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11437      function fmt2(n){{return Number(n).toLocaleString();}}
11438      function px(n){{return Math.round(n);}}
11439      var _tt=document.getElementById('mc-ic-tt');
11440      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11441      function addTT(el){{
11442        if(!el)return;
11443        el.addEventListener('mouseover',function(e){{
11444          var t=e.target.closest('[data-ttl]');
11445          if(t&&_tt){{
11446            var ttl=t.getAttribute('data-ttl');
11447            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11448            _tt.style.display='block';mvTT(e);
11449            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11450            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11451          }} else {{
11452            if(_tt)_tt.style.display='none';
11453            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11454          }}
11455        }});
11456        el.addEventListener('mouseleave',function(){{
11457          if(_tt)_tt.style.display='none';
11458          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11459        }});
11460        el.addEventListener('mousemove',function(e){{mvTT(e);}});
11461      }}
11462      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';}}
11463      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11464      function buildCharts(){{
11465        if(N<2)return;
11466        var cs=getComputedStyle(document.body);
11467        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11468        var textCol=cv('--text','#43342d');
11469        var mutedCol=cv('--muted','#7b675b');
11470        var gFill=cv('--muted-2','#a08777');
11471        var LGY=cv('--line','#e6d0bf');
11472        var axisCol=cv('--line-strong','#d8bfad');
11473        var surf2col=cv('--surface-2','#f4ede4');
11474        var surfCol=cv('--surface','#fff8f0');
11475        var p0=POINTS[0],pLast=POINTS[N-1];
11476        var dark=document.body.classList.contains('dark-theme');
11477        var FADE=dark?'#524238':'#e6d0bf';
11478        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11479        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;}}
11480      var c1mets=[
11481        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11482        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11483        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11484      ];
11485      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11486      // Code Metrics chart — grows to fill the height its grid row settled to (the
11487      // Language Code Delta sibling usually drives that), so it never sits short at
11488      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11489      function drawC1(){{
11490        var C1W=620,C1H=200;
11491        var c1host=document.getElementById('mc-ic-c1');
11492        var c1card=c1host?c1host.closest('.ic-card'):null;
11493        if(c1host&&c1card&&c1host.clientWidth>0){{
11494          var avW=c1host.clientWidth;
11495          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11496          var wantH=availPx*C1W/avW;
11497          if(wantH>C1H)C1H=wantH;
11498        }}
11499        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11500        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11501        for(var gi=1;gi<=4;gi++){{
11502          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11503          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11504          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11505        }}
11506        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11507        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11508        c1mets.forEach(function(m,i){{
11509          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11510          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11511          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11512          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;"/>';
11513          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>';
11514          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;"/>';
11515          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>';
11516          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>';
11517          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>';
11518        }});
11519        c1+='</svg>';
11520        return c1;
11521      }}
11522      // Chart 2: Delta by Metric (net delta first scan to last)
11523      var mets=[
11524        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11525        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11526        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11527      ];
11528      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11529      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;
11530      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11531      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11532      mets.forEach(function(m,i){{
11533        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);
11534        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>';
11535        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;"/>';
11536        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>';}}
11537        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>';}}
11538      }});
11539      c2+='</svg>';
11540      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11541      var lm={{}};
11542      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;}});
11543      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11544      function drawC3(){{
11545        if(!langs.length)return'';
11546        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11547        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;
11548        var c3host=document.getElementById('mc-ic-c3');
11549        var c3card=document.getElementById('mc-ic-lang-card');
11550        var C3H=langs.length*30+24;
11551        if(c3host&&c3card&&c3host.clientWidth>0){{
11552          var avW=c3host.clientWidth;
11553          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11554          var wantH=availPx*C3W/avW;
11555          if(wantH>C3H)C3H=wantH;
11556        }}
11557        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11558        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11559        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11560        langs.forEach(function(l,i){{
11561          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);
11562          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11563          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"/>';
11564          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>';}}
11565          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>';}}
11566          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>';
11567        }});
11568        c3+='</svg>';
11569        return c3;
11570      }}
11571      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11572      var fm=0,fa=0,fr=0,fu=0;
11573      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11574      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;}});
11575      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11576      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11577      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11578      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;
11579      if(segs.length===1){{
11580        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"/>';
11581        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11582        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>';
11583      }} else {{
11584        segs.forEach(function(s){{
11585          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11586          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);
11587          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);
11588          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"/>';
11589          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>';}}
11590          ang4+=sw;
11591        }});
11592      }}
11593      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11594      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11595      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11596      segs.forEach(function(s,i){{
11597        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11598        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;"/>';
11599        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>';
11600        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11601      }});
11602      c4+='</svg>';
11603      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11604      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11605      // once at natural height to seed the row, then both are filled to the row the
11606      // grid settled to, so neither sits short at the top of an over-tall cell.
11607      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11608      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11609      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11610      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11611      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>';
11612      if(e1)e1.innerHTML=drawC1();
11613      }}
11614      buildCharts();
11615      renderInlineCharts=buildCharts;
11616      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11617      (function(){{
11618        var ov=document.getElementById('ic-svg-modal-ov');
11619        var body=document.getElementById('ic-svg-modal-body');
11620        var ttl=document.getElementById('ic-svg-modal-title');
11621        var closeBtn=document.getElementById('ic-svg-modal-close');
11622        if(!ov||!body)return;
11623        function close(){{ov.classList.remove('open');body.innerHTML='';}}
11624        function open(srcId,title){{
11625          var src=document.getElementById(srcId);if(!src)return;
11626          ttl.textContent=title||'';
11627          var card=src.closest('.ic-card');
11628          var legHtml='';
11629          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11630          body.innerHTML=legHtml+src.innerHTML;
11631          var svg=body.querySelector('svg');
11632          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11633          addTT(body);
11634          ov.classList.add('open');
11635        }}
11636        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11637          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11638        }});
11639        if(closeBtn)closeBtn.addEventListener('click',close);
11640        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11641        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11642      }})();
11643
11644      // HTML legend hover → highlight matching SVG bars within the SAME card only
11645      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11646        var metric=leg.getAttribute('data-highlight');
11647        var parentCard=leg.closest('.ic-card');
11648        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11649        if(!chartEl)return;
11650        leg.addEventListener('mouseenter',function(){{
11651          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11652            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11653              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11654              x.style.opacity='1';
11655            }} else {{
11656              x.style.opacity='0.28';
11657            }}
11658          }});
11659        }});
11660        leg.addEventListener('mouseleave',function(){{
11661          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11662        }});
11663      }});
11664      // Author handles
11665      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11666
11667      // ── Export helpers ────────────────────────────────────────────────────────
11668      // Fetch one image from the server and return a data-URI Promise
11669      function mcFetchUri(path){{
11670        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11671          return new Promise(function(res){{
11672            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11673          }});
11674        }}).catch(function(){{return '';}});
11675      }}
11676      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11677      function mcInlineImgs(html,cb){{
11678        var paths=[],seen={{}};
11679        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11680        if(!paths.length){{cb(html);return;}}
11681        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11682          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11683          .catch(function(){{cb(html);}});
11684      }}
11685      // Capture full-page HTML with all table rows visible
11686      function mcRawHtml(pdfMode){{
11687        if(pdfMode)document.body.classList.add('pdf-mode');
11688        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11689        var html=document.documentElement.outerHTML;
11690        perPage=s;currentPage=p;renderFilePage();
11691        if(pdfMode)document.body.classList.remove('pdf-mode');
11692        return html;
11693      }}
11694
11695      // HTML export (full page with inlined images)
11696      function mcDoHtml(btn,fname){{
11697        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11698        mcInlineImgs(mcRawHtml(false),function(html){{
11699          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11700          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11701          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11702          btn.disabled=false;btn.innerHTML=orig;
11703        }});
11704      }}
11705      // PDF export — comprehensive document-style report: full numbers, all sections
11706      function mcBuildPdfHtml(){{
11707        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11708        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11709        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11710        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>';}}
11711        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11712        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11713        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)));}}
11714        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11715        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11716        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11717        // Header/footer flow in document order (NOT position:fixed) — a fixed
11718        // header repeats every printed page in Chromium and overlaps the content
11719        // below it, swallowing the first rows of pages 2+ and clipping the cards
11720        // on page 1. The table <thead> repeats per page natively, so every row
11721        // stays visible.
11722        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11723          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11724          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11725          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11726          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11727          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11728          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11729          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11730          '.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;}}'+
11731          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11732          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11733          '.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;}}'+
11734          '.body{{padding:12px 18px 0;}}'+
11735          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11736          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11737          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11738          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11739          '.sec{{margin-bottom:10px;}}'+
11740          '.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;}}'+
11741          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11742          '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;}}'+
11743          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11744          'tr:nth-child(even) td{{background:#faf8f6;}}';
11745        // ── Metric Progression ────────────────────────────────────────────────
11746        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11747        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11748        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>';
11749        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11750        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11751        var progRows=POINTS.map(function(pt,i){{
11752          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)));
11753          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11754            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11755            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11756            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11757            '<td style="text-align:right">'+full(pt.files)+'</td>';
11758          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11759          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11760          return r+'</tr>';
11761        }}).join('');
11762        // ── Scan-to-scan changes ──────────────────────────────────────────────
11763        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11764          var prev=POINTS[i];
11765          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11766          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11767          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11768            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11769            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11770            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11771            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11772        }}).join(''):'';
11773        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11774        var fmSection='';
11775        if(FILES&&FILES.length){{
11776          // Hard cap on per-scan columns so the table never overflows the page width.
11777          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11778          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11779          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11780          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11781          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11782          var fmRows=topFiles.map(function(f){{
11783            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11784            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>';
11785            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11786            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11787            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>';
11788          }}).join('');
11789          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11790          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11791            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11792        }}
11793        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11794          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11795          '<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>'+
11796
11797          '<div class="body">'+
11798          '<div class="sg">'+
11799          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11800            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11801          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11802            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11803          (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>':
11804            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11805          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11806          '</div>'+
11807          '<div class="sec"><p class="sh">Metric Progression</p>'+
11808          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11809          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11810          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11811          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11812          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11813          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11814          fmSection+
11815          '</div>'+
11816          '<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>'+
11817          '</body></html>';
11818      }}
11819      function mcDoPdf(btn){{
11820        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11821      }}
11822
11823      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11824      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11825      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11826      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11827      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11828      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11829      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11830      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11831      if(location.protocol==='file:'){{
11832        [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';}}}} );
11833        [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';}}}} );
11834      }}
11835    }})();
11836    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11837    (function(){{
11838      function $(id){{return document.getElementById(id);}}
11839      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11840      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11841      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11842      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11843      function openModal(idx){{
11844        var ov=$('mc-modal-overlay');if(!ov)return;
11845        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11846        if(idx<0||idx>=N)return;
11847        var pt=POINTS[idx];
11848        titleEl.textContent='Scan '+(idx+1);
11849        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11850        subEl.textContent=lbl;
11851        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11852          '<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>'+
11853          '<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>'+
11854          '<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>'+
11855          '<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>'+
11856          (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>':'')+
11857          (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>':'')+
11858          '</div></div>';
11859        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11860          (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>':'')+
11861          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11862          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11863          (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>':'')+
11864          (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>':'')+
11865          (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>':'')+
11866          (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>':'')+
11867          '<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>'+
11868          '</div>';
11869        var dHtml='';
11870        if(idx>0){{
11871          var prev=POINTS[idx-1];
11872          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11873          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11874            '<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>'+
11875            '<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>'+
11876            '<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>'+
11877            '</div></div>';
11878        }}
11879        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11880        ov.classList.add('open');document.body.style.overflow='hidden';
11881      }}
11882      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11883      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11884      document.addEventListener('click',function(e){{
11885        if(!e.target||!e.target.closest)return;
11886        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11887        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11888        var card=e.target.closest('.mc-card');
11889        if(!card)return;
11890        if(e.target.closest('a'))return;
11891        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11892        var i=cards.indexOf(card);
11893        if(i>=0)openModal(i);
11894      }});
11895      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11896      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11897      var statTip=null;
11898      document.addEventListener('mousemove',function(e){{
11899        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11900        if(!box){{if(statTip)statTip.style.display='none';return;}}
11901        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11902        var tip=box.getAttribute('data-tip')||'';
11903        if(statTip.textContent!==tip)statTip.textContent=tip;
11904        statTip.style.display='block';
11905        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11906        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11907        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11908        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11909      }});
11910      (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');}})();
11911    }})();
11912  }})();
11913  </script>
11914  <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]';
11915  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;}}
11916  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>
11917  <!-- Scan card detail modal -->
11918  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11919    <div class="mc-modal" id="mc-modal">
11920      <div class="mc-modal-head">
11921        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11922        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11923      </div>
11924      <div class="mc-modal-body" id="mc-modal-body"></div>
11925    </div>
11926  </div>
11927  {toast_assets}
11928</body>
11929</html>"#,
11930        project_label = html_escape(project_label),
11931        n = n,
11932        scan_strip = scan_strip,
11933        mc_strip_class = mc_strip_class,
11934        metrics_thead = metrics_thead,
11935        metrics_tbody = metrics_tbody,
11936        file_col_headers = file_col_headers,
11937        total_files = total_files,
11938        files_modified = files_modified,
11939        files_added = files_added,
11940        files_removed = files_removed,
11941        files_unchanged = files_unchanged,
11942        points_json = points_json,
11943        file_matrix_json = file_matrix_json,
11944        nav_compare_active = nav_compare_active,
11945        version = version,
11946        csp_nonce = csp_nonce,
11947        scope_bar_html = scope_bar_html,
11948        scope_label = scope_label,
11949        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11950    )
11951}
11952
11953// ── Trend report page ─────────────────────────────────────────────────────────
11954// Protected. Interactive time-series chart page that loads scan history via
11955// /api/metrics/history and renders a vanilla-SVG line chart.
11956//
11957// GET /trend-reports
11958
11959#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11960async fn trend_report_handler(
11961    State(state): State<AppState>,
11962    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11963) -> Response {
11964    auto_scan_watched_dirs(&state).await;
11965
11966    let watched_dirs_list: Vec<String> = {
11967        let wd = state.watched_dirs.lock().await;
11968        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11969    };
11970
11971    // Collect distinct project roots for the root selector dropdown.
11972    let roots: Vec<String> = {
11973        let reg = state.registry.lock().await;
11974        let mut seen = std::collections::BTreeSet::new();
11975        reg.entries
11976            .iter()
11977            .flat_map(|e| e.input_roots.iter().cloned())
11978            .filter(|r| seen.insert(r.clone()))
11979            .collect()
11980    };
11981
11982    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11983    let nonce = &csp_nonce;
11984    let version = env!("CARGO_PKG_VERSION");
11985    let toast_assets = sloc_toast_assets(nonce);
11986
11987    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11988    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11989    // of interactive controls — folder watching is managed by the host administrator.
11990    let watched_dirs_html: String = if state.server_mode {
11991        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()
11992    } else {
11993        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11994            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11995                .to_string()
11996        } else {
11997            watched_dirs_list
11998                .iter()
11999                .fold(String::new(), |mut s, d| {
12000                    use std::fmt::Write as _;
12001                    let escaped =
12002                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
12003                    write!(
12004                        s,
12005                        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>"#
12006                    ).expect("write to String is infallible");
12007                    s
12008                })
12009        };
12010        format!(
12011            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>"#
12012        )
12013    };
12014
12015    let html = format!(
12016        r##"<!doctype html>
12017<html lang="en">
12018<head>
12019  <meta charset="utf-8" />
12020  <meta name="viewport" content="width=device-width, initial-scale=1" />
12021  <title>OxideSLOC | Trend Reports</title>
12022  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12023  <style nonce="{nonce}">
12024    :root {{
12025      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12026      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12027      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12028      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12029      --info-bg:#eef3ff; --info-text:#4467d8;
12030    }}
12031    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12032    *{{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;}}
12033    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12034    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12035    .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;}}
12036    @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));}}}}
12037    .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);}}
12038    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12039    .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));}}
12040    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12041    .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;}}
12042    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12043    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12044    @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; }} }}
12045    .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;}}
12046    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12047    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12048    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12049    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12050    .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;}}
12051    .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;}}
12052    .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;}}
12053    .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);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;}}
12054    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12055    .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);}}
12056    .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;}}
12057    .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;}}
12058    .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;}}
12059    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12060    .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;}}
12061    .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);}}
12062    .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;}}
12063    .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;}}
12064    .tz-select:focus{{border-color:var(--oxide);}}
12065    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12066    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12067    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12068    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12069    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12070    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
12071    .trend-title-block{{flex:1;min-width:0;}}
12072    .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;}}
12073    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
12074    .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;}}
12075    .chart-select:focus{{border-color:var(--accent);}}
12076    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12077    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12078    .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);}}
12079    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12080    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12081    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12082    .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);}}
12083    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12084    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
12085    .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;}}
12086    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
12087    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
12088    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
12089    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12090    .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;}}
12091    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12092    .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;}}
12093    .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);}}
12094    .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;}}
12095    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
12096    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
12097    .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);}}
12098    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
12099    .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;}}
12100    .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;}}
12101    .data-table tr:last-child td{{border-bottom:none;}}
12102    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
12103    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
12104    .table-wrap{{width:100%;overflow-x:auto;}}
12105    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
12106    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
12107    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
12108    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
12109    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
12110    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
12111    .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;}}
12112    .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;}}
12113    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
12114    .pagination-info{{font-size:13px;color:var(--muted);}}
12115    .pagination-btns{{display:flex;gap:6px;}}
12116    .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;}}
12117    .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;}}
12118    #scan-history-table col:nth-child(1){{width:155px;}}
12119    #scan-history-table col:nth-child(2){{width:240px;}}
12120    #scan-history-table col:nth-child(3){{width:82px;}}
12121    #scan-history-table col:nth-child(4){{width:82px;}}
12122    #scan-history-table col:nth-child(5){{width:90px;}}
12123    #scan-history-table col:nth-child(6){{width:90px;}}
12124    #scan-history-table col:nth-child(7){{width:88px;}}
12125    #scan-history-table col:nth-child(8){{width:150px;}}
12126    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
12127    .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;}}
12128    .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;}}
12129    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
12130    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
12131    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12132    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12133    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12134    .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;}}
12135    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12136    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12137    .watched-chip-rm:hover{{color:var(--oxide);}}
12138    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12139    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12140    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12141    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12142    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
12143    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
12144    a.run-link:hover{{text-decoration:underline;}}
12145    .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);}}
12146    .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);}}
12147    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
12148    .metric-num{{font-weight:700;color:var(--text);}}
12149    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
12150    .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;}}
12151    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
12152    .btn.primary:hover{{opacity:.9;}}
12153    .rpt-btn{{min-width:58px;justify-content:center;}}
12154    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
12155    .report-cell{{overflow:visible!important;white-space:normal!important;}}
12156    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
12157    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
12158    .submod-details summary::-webkit-details-marker{{display:none;}}
12159    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
12160    .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;}}
12161    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
12162    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
12163    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
12164    .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;}}
12165    .export-btn:hover{{background:var(--line);}}
12166    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
12167    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12168    .site-footer a{{color:var(--muted);}}
12169    .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;}}
12170    .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;}}
12171    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
12172    /* Modal system (Retention Policy / Clean-up) */
12173    .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;}}
12174    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
12175    .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);}}
12176    .tr-modal{{background:rgba(255,255,255,0.90);}}
12177    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
12178    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
12179    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
12180    .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);}}
12181    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
12182    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
12183    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
12184    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
12185    .tr-modal-body{{padding:22px 30px;}}
12186    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
12187    .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;}}
12188    .tr-btn:hover{{transform:translateY(-1px);}}
12189    .tr-btn:active{{transform:translateY(0);}}
12190    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
12191    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
12192    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
12193    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
12194    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
12195    .tr-btn-secondary:hover{{background:var(--line);}}
12196    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
12197    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
12198  </style>
12199</head>
12200<body>
12201  <div class="background-watermarks" aria-hidden="true">
12202    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12203    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12204    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12205    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12206    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12207    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12208  </div>
12209  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12210  <div class="top-nav">
12211    <div class="top-nav-inner">
12212      <a class="brand" href="/">
12213        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12214        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
12215      </a>
12216      <div class="nav-right">
12217        <a class="nav-pill" href="/">Home</a>
12218        <div class="nav-dropdown">
12219          <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>
12220          <div class="nav-dropdown-menu">
12221            <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>
12222          </div>
12223        </div>
12224        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12225        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12226        <div class="nav-dropdown">
12227          <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>
12228          <div class="nav-dropdown-menu">
12229            <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>
12230          </div>
12231        </div>
12232        <div class="server-status-wrap" id="server-status-wrap">
12233          <div class="nav-pill server-online-pill" id="server-status-pill">
12234            <span class="status-dot" id="status-dot"></span>
12235            <span id="server-status-label">Server</span>
12236            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12237          </div>
12238          <div class="server-status-tip">
12239            OxideSLOC is running — accessible on your network.
12240            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12241          </div>
12242        </div>
12243        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12244          <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>
12245        </button>
12246        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12247          <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>
12248          <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>
12249        </button>
12250      </div>
12251    </div>
12252  </div>
12253
12254  <div class="page">
12255    {watched_dirs_html}
12256    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12257      <div class="scan-overlay-card">
12258        <div class="scan-spinner"></div>
12259        <div class="scan-overlay-text">Scanning folder…</div>
12260        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12261      </div>
12262    </div>
12263    <style>
12264    .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
12265    .scan-overlay.active{{display:flex;}}
12266    .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
12267    .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
12268    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12269    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12270    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12271    </style>
12272    <div class="summary-strip" id="trend-stats"></div>
12273    <div class="panel">
12274      <div class="trend-header">
12275        <div class="trend-title-block">
12276          <h1>Trend Reports</h1>
12277          <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>
12278          <span class="chart-hint-inline">
12279            <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>
12280            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
12281          </span>
12282        </div>
12283        <div class="chart-actions">
12284          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12285            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12286            Retention Policy
12287          </button>
12288          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12289            <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>
12290            Clean up old runs
12291          </button>
12292          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12293            <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>
12294            Export Excel
12295          </button>
12296          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12297            <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>
12298            Export PNG
12299          </button>
12300          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12301            <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>
12302            Export PDF
12303          </button>
12304        </div>
12305      </div>
12306
12307      <div class="controls-centered">
12308        <label>Project Root:
12309          <select class="chart-select" id="root-sel">
12310            <option value="">All projects</option>
12311          </select>
12312        </label>
12313        <label>Y Metric:
12314          <select class="chart-select" id="y-sel">
12315            <option value="code_lines">Code Lines</option>
12316            <option value="comment_lines">Comment Lines</option>
12317            <option value="blank_lines">Blank Lines</option>
12318            <option value="physical_lines">Physical Lines</option>
12319            <option value="files_analyzed">Files Analyzed</option>
12320          </select>
12321        </label>
12322        <label>X Axis:
12323          <select class="chart-select" id="x-sel">
12324            <option value="time">By Time</option>
12325            <option value="commit" selected>By Commit</option>
12326            <option value="release">By Release</option>
12327            <option value="tag">Tagged Commits</option>
12328          </select>
12329        </label>
12330        <label id="submodule-label" style="display:none;">Submodule:
12331          <select class="chart-select" id="sub-sel">
12332            <option value="">All (project total)</option>
12333          </select>
12334        </label>
12335        <label>Chart Size:
12336          <select class="chart-select" id="scale-sel">
12337            <option value="0.75">Compact</option>
12338            <option value="1.2" selected>Normal</option>
12339            <option value="1.38">Large</option>
12340          </select>
12341        </label>
12342        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
12343      </div>
12344
12345      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12346      <div id="data-table-wrap" style="overflow-x:auto;"></div>
12347    </div>
12348  </div>
12349
12350  <script nonce="{nonce}">
12351    (function() {{
12352      // Theme persistence
12353      var b = document.body;
12354      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12355      var tgl = document.getElementById('theme-toggle');
12356      if (tgl) tgl.addEventListener('click', function() {{
12357        var d = b.classList.toggle('dark-theme');
12358        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12359      }});
12360
12361      // Watermark randomizer
12362      (function() {{
12363        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12364        if (!wms.length) return;
12365        var placed = [];
12366        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;}}
12367        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];}}
12368        var half=Math.floor(wms.length/2);
12369        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;}});
12370      }})();
12371
12372      // Code particles
12373      (function() {{
12374        var container = document.getElementById('code-particles');
12375        if (!container) return;
12376        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'];
12377        for (var i = 0; i < 38; i++) {{
12378          (function(idx) {{
12379            var el = document.createElement('span');
12380            el.className = 'code-particle';
12381            el.textContent = snippets[idx % snippets.length];
12382            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12383            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12384            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12385            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';
12386            container.appendChild(el);
12387          }})(i);
12388        }}
12389      }})();
12390
12391      // Watched folder picker
12392      (function(){{
12393        window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
12394        document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
12395      }})();
12396      (function() {{
12397        var btn = document.getElementById('add-watched-btn');
12398        if (!btn) return;
12399        btn.addEventListener('click', function() {{
12400          fetch('/pick-directory?kind=reports')
12401            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12402            .then(function(data) {{
12403              if (!data.cancelled && data.selected_path) {{
12404                var form = document.createElement('form');
12405                form.method = 'POST';
12406                form.action = '/watched-dirs/add';
12407                var ri = document.createElement('input');
12408                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12409                var fi = document.createElement('input');
12410                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12411                form.appendChild(ri); form.appendChild(fi);
12412                document.body.appendChild(form);
12413                if (window.__scanOverlay) window.__scanOverlay();
12414                form.submit();
12415              }}
12416            }})
12417            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12418        }});
12419      }})();
12420
12421      // Settings / color-scheme modal
12422      (function() {{
12423        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'}}];
12424        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);}});}}
12425        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12426        var btn=document.getElementById('settings-btn');if(!btn)return;
12427        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12428        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>';
12429        document.body.appendChild(m);
12430        var g=document.getElementById('scheme-grid');
12431        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);}});
12432        var cl=document.getElementById('settings-close');
12433        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.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};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);}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){{var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}})();
12434        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');}});
12435        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12436        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12437      }})();
12438    }})();
12439
12440    var ROOTS = {roots_json};
12441    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12442    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12443    var allData = [];
12444
12445    // Populate root selector
12446    var rootSel = document.getElementById('root-sel');
12447    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12448
12449    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();}}
12450    function fmtFull(n){{return Number(n).toLocaleString();}}
12451    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
12452
12453    // Tooltip
12454    var tt = document.createElement('div');
12455    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);';
12456    document.body.appendChild(tt);
12457    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12458    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';}}
12459    function hideTT(){{tt.style.display='none';}}
12460    window.addEventListener('blur',function(){{hideTT();}});
12461    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12462
12463    function statExact(compact, full){{
12464      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12465    }}
12466    function statVal(n){{
12467      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12468    }}
12469
12470    function updateStats(data){{
12471      var statsEl=document.getElementById('trend-stats');
12472      if(!statsEl)return;
12473      if(!data||!data.length){{statsEl.innerHTML='';return;}}
12474      var yKey=document.getElementById('y-sel').value;
12475      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12476      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12477      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12478      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12479      var absDelta=Math.abs(delta);
12480      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12481      var deltaExact=statExact(deltaCompact,deltaFull);
12482      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12483      statsEl.innerHTML=
12484        '<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>'+
12485        '<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>'+
12486        '<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>'+
12487        '<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>';
12488    }}
12489
12490    var subSel = document.getElementById('sub-sel');
12491    var subLabel = document.getElementById('submodule-label');
12492
12493    function populateSubmodules(root){{
12494      if(!subSel||!subLabel)return;
12495      while(subSel.options.length>1)subSel.remove(1);
12496      subSel.value='';
12497      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12498      fetch(url)
12499        .then(function(r){{return r.json();}})
12500        .then(function(subs){{
12501          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12502          subs.forEach(function(s){{
12503            var o=document.createElement('option');
12504            o.value=s.name;
12505            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12506            subSel.appendChild(o);
12507          }});
12508          subLabel.style.display='';
12509        }})
12510        .catch(function(){{subLabel.style.display='none';}});
12511    }}
12512
12513    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12514
12515    function loadAndRender(){{
12516      var root = rootSel.value;
12517      var sub = subSel ? subSel.value : '';
12518      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12519      document.getElementById('data-table-wrap').innerHTML='';
12520      var url = '/api/metrics/history?limit=100'
12521        + (root ? '&root='+encodeURIComponent(root) : '')
12522        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
12523      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12524        allData = data;
12525        render(data);
12526        updateStats(data);
12527      }}).catch(function(){{
12528        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>';
12529      }});
12530    }}
12531
12532    function render(data){{
12533      var yKey = document.getElementById('y-sel').value;
12534      var xMode = document.getElementById('x-sel').value;
12535
12536      // Filter for tag/release mode
12537      var pts = data;
12538      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12539
12540      // Sort oldest-first for the line chart
12541      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12542
12543      var wrap = document.getElementById('chart-wrap');
12544      if(!pts.length){{
12545        var emptyMsg = (xMode === 'tag')
12546          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12547          : 'No scan data found for the selected filters.';
12548        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12549        renderTable([]);
12550        return;
12551      }}
12552
12553      var scaleEl=document.getElementById('scale-sel');
12554      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12555      renderTrendInto(wrap, pts, yKey, xMode, sc);
12556      renderTable(pts, yKey);
12557    }}
12558
12559    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12560    // Shared by the inline chart and the Full View modal so both render identically.
12561    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12562      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12563      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12564      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12565      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;
12566      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12567
12568      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12569
12570      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">';
12571      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>';
12572
12573      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12574
12575      // Grid + Y axis ticks
12576      for(var ti=0;ti<=5;ti++){{
12577        var gy=PT+CH-Math.round(ti/5*CH);
12578        var gv=Math.round(ti/5*maxY);
12579        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12580        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12581      }}
12582
12583      // X axis labels (every N-th point to avoid crowding)
12584      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12585      pts.forEach(function(d,i){{
12586        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12587        if(i%labelEvery===0||i===pts.length-1){{
12588          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)));
12589          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>';
12590        }}
12591      }});
12592
12593      // Axis label
12594      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12595      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>';
12596      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>';
12597
12598      // Area fill + line path
12599      var pathD='';
12600      pts.forEach(function(d,i){{
12601        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12602        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12603        pathD+=(i===0?'M':'L')+x+','+y;
12604      }});
12605      if(pts.length>1){{
12606        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12607        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12608      }}
12609      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12610
12611      // Data points (clickable) + permanent value labels
12612      var showLabels = pts.length <= 40;
12613      var labelEveryN = pts.length > 20 ? 2 : 1;
12614      pts.forEach(function(d,i){{
12615        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12616        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12617        var hasTags=d.tags&&d.tags.length>0;
12618        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12619        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12620        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+'"/>';
12621        if(showLabels && i%labelEveryN===0){{
12622          var lx=x, ly=y-r-5;
12623          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>';
12624        }}
12625      }});
12626
12627      svg+='</svg>';
12628      wrap.innerHTML=svg;
12629
12630      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12631      function lineYAt(mx){{
12632        var n=pts.length;
12633        if(n===0)return PT+CH;
12634        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12635        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12636        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12637        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12638        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12639        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12640        return y0+t*(y1-y0);
12641      }}
12642
12643      // SVG-level mousemove: show the value tooltip only when the pointer is over the
12644      // gradient fill (inside the chart and at/below the line) — never in the empty
12645      // space above the line. Cursor follows the same rule.
12646      (function(){{
12647        var svgEl=wrap.querySelector('svg');
12648        if(!svgEl)return;
12649        svgEl.addEventListener('mousemove',function(e){{
12650          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12651          var rect=svgEl.getBoundingClientRect();
12652          var scaleX=W/Math.max(rect.width,1);
12653          var scaleY=H/Math.max(rect.height,1);
12654          var mouseX=(e.clientX-rect.left)*scaleX;
12655          var mouseY=(e.clientY-rect.top)*scaleY;
12656          var ly=lineYAt(mouseX);
12657          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12658          svgEl.style.cursor='pointer';
12659          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12660          var d=pts[idx];
12661          var val=Number(d[yKey]);
12662          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12663          showTT(e,
12664            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12665            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12666            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12667          );
12668        }});
12669        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12670      }})();
12671
12672      // Attach point tooltips
12673      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12674        c.addEventListener('mouseover',function(e){{
12675          var d=pts[parseInt(this.dataset.idx)];
12676          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(''):'';
12677          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>':'';
12678          showTT(e,
12679            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12680            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12681            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12682            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12683          );
12684          this.setAttribute('r','8');
12685        }});
12686        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12687        c.addEventListener('mousemove',moveTT);
12688        c.addEventListener('click',function(){{
12689          var d=pts[parseInt(this.dataset.idx)];
12690          if(d.html_url) window.open(d.html_url,'_blank');
12691        }});
12692      }});
12693    }}
12694
12695    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12696    var shProjFilter='', shBranchFilter='';
12697
12698    function fmtPST(isoStr){{
12699      if(!isoStr)return'';
12700      var d=new Date(isoStr);
12701      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12702      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);}}
12703      function p(n){{return n<10?'0'+n:String(n);}}
12704      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++;}}}}
12705      var yr=d.getUTCFullYear();
12706      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12707      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12708      var isDST=d>=dstStart&&d<dstEnd;
12709      var off=isDST?-7*3600*1000:-8*3600*1000;
12710      var lbl=isDST?'PDT':'PST';
12711      var loc=new Date(d.getTime()+off);
12712      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12713    }}
12714
12715    function getShRows(){{
12716      var proj=shProjFilter.toLowerCase().trim();
12717      var branch=shBranchFilter;
12718      return shData.filter(function(d){{
12719        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12720        if(branch&&(d.branch||'')!==branch)return false;
12721        return true;
12722      }});
12723    }}
12724
12725    function renderShPage(){{
12726      var filtered=getShRows();
12727      if(shSortCol){{
12728        filtered.sort(function(a,b){{
12729          var va,vb;
12730          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12731          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12732          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12733          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12734          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12735          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12736        }});
12737      }}
12738      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12739      shPage=Math.min(shPage,totalPages);
12740      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12741      var visible=filtered.slice(start,end);
12742      var tbody=document.getElementById('sh-tbody');
12743      if(!tbody)return;
12744      tbody.innerHTML=visible.map(function(d){{
12745        var tsHtml=esc(fmtPST(d.timestamp));
12746        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>';
12747        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>';
12748        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12749        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12750        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12751        var reportCell='';
12752        if(d.html_url){{
12753          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12754          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>';}}
12755          reportCell+='</div>';
12756        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12757        if(d.submodule_links&&d.submodule_links.length){{
12758          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12759          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12760          reportCell+='</div></details>';
12761        }}
12762        return '<tr>'
12763          +'<td>'+tsHtml+'</td>'
12764          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12765          +'<td>'+runIdHtml+'</td>'
12766          +'<td>'+commitHtml+'</td>'
12767          +'<td>'+branchHtml+'</td>'
12768          +'<td>'+tags+'</td>'
12769          +'<td class="num">'+metricHtml+'</td>'
12770          +'<td class="report-cell">'+reportCell+'</td>'
12771          +'</tr>';
12772      }}).join('');
12773      var pgRange=document.getElementById('sh-pg-range');
12774      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12775      var pgInfo=document.getElementById('sh-pg-info');
12776      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12777      var pgBtns=document.getElementById('sh-pg-btns');
12778      if(pgBtns){{
12779        pgBtns.innerHTML='';
12780        function mkPgBtn(lbl,pg,active,disabled){{
12781          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12782          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12783          return b;
12784        }}
12785        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12786        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12787        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12788        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12789      }}
12790    }}
12791
12792    function wireTableBehavior(){{
12793      var pf=document.getElementById('sh-proj-filter');
12794      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12795      var bf=document.getElementById('sh-branch-filter');
12796      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12797      var rb=document.getElementById('sh-reset-btn');
12798      if(rb)rb.addEventListener('click',function(){{
12799        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12800        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12801        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12802        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');}});
12803        renderShPage();
12804      }});
12805      var pps=document.getElementById('sh-per-page');
12806      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12807      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12808      ths.forEach(function(th){{
12809        th.addEventListener('click',function(e){{
12810          if(e.target.classList.contains('col-resize-handle'))return;
12811          var col=th.dataset.col;
12812          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12813          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12814          th.classList.add('sort-'+shSortOrder);
12815          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12816          shPage=1;renderShPage();
12817        }});
12818      }});
12819      var table=document.getElementById('scan-history-table');
12820      if(!table)return;
12821      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12822      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12823      allThs.forEach(function(th,i){{
12824        var handle=th.querySelector('.col-resize-handle');
12825        if(!handle||!cols[i])return;
12826        var startX,startW;
12827        handle.addEventListener('mousedown',function(e){{
12828          e.stopPropagation();e.preventDefault();
12829          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12830          handle.classList.add('dragging');
12831          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12832          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12833          document.addEventListener('mousemove',onMove);
12834          document.addEventListener('mouseup',onUp);
12835        }});
12836      }});
12837    }}
12838
12839    function renderTable(pts, yKey){{
12840      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12841      var wrap=document.getElementById('data-table-wrap');
12842      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12843      var yLabel=Y_LABELS[yKey]||yKey||'';
12844      shData=pts.slice().reverse();
12845      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12846      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12847      var branches={{}};
12848      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12849      var branchOpts='<option value="">All branches</option>';
12850      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12851      wrap.innerHTML=
12852        '<div class="chart-section-header">SCAN HISTORY</div>'+
12853        '<div class="filter-row">'+
12854          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12855          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12856          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12857        '</div>'+
12858        '<div class="table-wrap">'+
12859        '<table id="scan-history-table" class="data-table">'+
12860        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12861        '<thead><tr id="sh-thead">'+
12862        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12863        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12864        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12865        '<th>Commit<div class="col-resize-handle"></div></th>'+
12866        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12867        '<th>Tags<div class="col-resize-handle"></div></th>'+
12868        '<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>'+
12869        '<th>Report<div class="col-resize-handle"></div></th>'+
12870        '</tr></thead>'+
12871        '<tbody id="sh-tbody"></tbody>'+
12872        '</table>'+
12873        '</div>'+
12874        '<div class="pagination">'+
12875          '<span class="pagination-info" id="sh-pg-info"></span>'+
12876          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12877          '<div style="display:flex;align-items:center;gap:8px;">'+
12878            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12879            '<select class="filter-select" id="sh-per-page">'+
12880              '<option value="10">10 per page</option>'+
12881              '<option value="25" selected>25 per page</option>'+
12882              '<option value="50">50 per page</option>'+
12883              '<option value="100">100 per page</option>'+
12884            '</select>'+
12885            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12886          '</div>'+
12887        '</div>';
12888      wireTableBehavior();
12889      renderShPage();
12890    }}
12891
12892    function exportXLSX(){{
12893      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12894      var xbtn=document.getElementById('export-xlsx-btn');
12895      var xorig=xbtn?xbtn.innerHTML:'';
12896      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12897      var root=rootSel.value;
12898      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12899      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12900        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12901        buildAndDownloadXLSX(cm);
12902      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12903    }}
12904
12905    function buildAndDownloadXLSX(churnMap){{
12906      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12907      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12908      // (sorted is newest-first), so a given project/commit appears at most once.
12909      var seenPC={{}},dedup=[];
12910      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12911      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12912      var s1R=dedup.map(function(d){{
12913        var c=churnMap[d.run_id]||{{}};
12914        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,(+(c.added)||0)+(+(c.removed)||0)+(+(c.modified)||0)+(+(c.unmodified)||0)];
12915      }});
12916      var pm={{}};
12917      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12918      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'];
12919      var s2R=Object.keys(pm).map(function(p){{
12920        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12921        var lat=sc[sc.length-1],fst=sc[0];
12922        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12923        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);
12924        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];
12925      }});
12926      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12927      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12928      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12929      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12930    }}
12931
12932    function buildXLSX(sheets,chartRows,chartRows2){{
12933      function s2b(s){{return new TextEncoder().encode(s);}}
12934      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12935      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;}}
12936      function crc32(d){{
12937        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;}}}}
12938        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12939      }}
12940      function buildSheet(hdr,rows,drawRid,withCtrl){{
12941        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12942        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12943        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12944        x+='<row r="1">';
12945        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12946        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12947        x+='</row>';
12948        rows.forEach(function(row,ri){{
12949          var rn=ri+2;
12950          x+='<row r="'+rn+'">';
12951          row.forEach(function(cell,ci){{
12952            var addr=col2l(ci+1)+rn;
12953            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12954            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12955          }});
12956          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\",\"Total\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+",P"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12957          x+='</row>';
12958        }});
12959        x+='</sheetData>';
12960        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12961        return x+'</worksheet>';
12962      }}
12963      function buildChartXML(rows){{
12964        var sn="'Scan History'";
12965        var nr=rows.length,er=nr+1;
12966        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'}}];
12967        var catCol='C',catIdx=2;
12968        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12969        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">';
12970        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12971        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>';
12972        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12973        sd.forEach(function(s,i){{
12974          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12975          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>';
12976          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12977          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>';
12978          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12979          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12980          x+='</c:strCache></c:strRef></c:cat>';
12981          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+'"/>';
12982          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12983          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12984        }});
12985        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12986        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>';
12987        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>';
12988        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12989        return x;
12990      }}
12991      function buildChartXML2(rows){{
12992        var sn="'By Project'";
12993        var nr=rows.length,er=nr+1;
12994        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'}}];
12995        var catCol='A',catIdx=0;
12996        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12997        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">';
12998        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12999        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>';
13000        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13001        sd.forEach(function(s,i){{
13002          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
13003          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>';
13004          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
13005          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>';
13006          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13007          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13008          x+='</c:strCache></c:strRef></c:cat>';
13009          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+'"/>';
13010          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
13011          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13012        }});
13013        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
13014        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>';
13015        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>';
13016        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
13017        return x;
13018      }}
13019      function buildChartXML3(rows){{
13020        var sn="'Scan History'";
13021        var nr=rows.length,er=nr+1;
13022        var catCol='C',catIdx=2;
13023        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13024        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">';
13025        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
13026        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13027        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
13028        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>";
13029        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
13030        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>';
13031        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>';
13032        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13033        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13034        x+='</c:strCache></c:strRef></c:cat>';
13035        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+'"/>';
13036        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
13037        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13038        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
13039        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>';
13040        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>';
13041        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>';
13042        return x;
13043      }}
13044      function buildFocusSheet(drawRid){{
13045        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
13046        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
13047        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
13048        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
13049        x+='<sheetData><row r="1">';
13050        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
13051        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
13052        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
13053        x+='</row></sheetData>';
13054        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,Total"</formula1></dataValidation></dataValidations>';
13055        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
13056        return x+'</worksheet>';
13057      }}
13058      var hasChart=!!(chartRows&&chartRows.length);
13059      var nr=hasChart?chartRows.length:0;
13060      var hasChart2=!!(chartRows2&&chartRows2.length);
13061      var nr2=hasChart2?chartRows2.length:0;
13062      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>';
13063      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"/>';
13064      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
13065      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"/>';}}
13066      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"/>';}}
13067      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
13068      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>';
13069      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
13070      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"/>';}});
13071      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
13072      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>';
13073      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
13074      wbx+='</sheets></workbook>';
13075      var files=[
13076        {{name:'[Content_Types].xml',data:s2b(ct)}},
13077        {{name:'_rels/.rels',data:s2b(dotrels)}},
13078        {{name:'xl/workbook.xml',data:s2b(wbx)}},
13079        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
13080        {{name:'xl/styles.xml',data:s2b(styl)}}
13081      ];
13082      // Chart embedded directly in Scan History (sheet1); By Project is plain
13083      sheets.forEach(function(s,i){{
13084        var sx;
13085        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
13086        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
13087        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
13088      }});
13089      if(hasChart){{
13090        var fromRow=nr+4,toRow=nr+34;
13091        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>')}});
13092        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13093        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">';
13094        drx+='<xdr:twoCellAnchor editAs="twoCell">';
13095        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>';
13096        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>';
13097        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13098        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13099        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13100        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13101        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13102        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
13103        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>')}});
13104        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
13105        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>')}});
13106        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13107        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">';
13108        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
13109        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>';
13110        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>';
13111        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13112        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13113        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13114        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13115        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13116        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
13117        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>')}});
13118        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
13119      }}
13120      if(hasChart2){{
13121        var fromRow2=nr2+4,toRow2=nr2+36;
13122        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>')}});
13123        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13124        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">';
13125        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
13126        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>';
13127        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>';
13128        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13129        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13130        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13131        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13132        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
13133        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
13134        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>')}});
13135        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
13136      }}
13137      var parts=[],offsets=[],total=0;
13138      files.forEach(function(f){{
13139        offsets.push(total);
13140        var nb=s2b(f.name),crc=crc32(f.data);
13141        var h=new DataView(new ArrayBuffer(30+nb.length));
13142        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
13143        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
13144        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
13145        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
13146        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
13147        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
13148        total+=30+nb.length+f.data.length;
13149      }});
13150      var cdStart=total;
13151      files.forEach(function(f,fi){{
13152        var nb=s2b(f.name),crc=crc32(f.data);
13153        var cd=new DataView(new ArrayBuffer(46+nb.length));
13154        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
13155        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
13156        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
13157        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
13158        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
13159        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
13160        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
13161      }});
13162      var cdSz=total-cdStart;
13163      var eocd=new DataView(new ArrayBuffer(22));
13164      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
13165      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
13166      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
13167      parts.push(new Uint8Array(eocd.buffer));
13168      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
13169      var out=new Uint8Array(sz);var off=0;
13170      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
13171      return out.buffer;
13172    }}
13173
13174    function trendTitleParts(){{
13175      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
13176      var subSelEl=document.getElementById('sub-sel');
13177      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
13178      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
13179      var proj=(document.getElementById('root-sel').value)||'All projects';
13180      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
13181      var cnt=(allData&&allData.length)||0;
13182      var now=new Date();
13183      function p2(n){{return(n<10?'0':'')+n;}}
13184      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
13185      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
13186    }}
13187
13188    function exportPNG(){{
13189      var svgEl=document.querySelector('#chart-wrap svg');
13190      if(!svgEl){{alert('No chart to export yet.');return;}}
13191      var svgStr=new XMLSerializer().serializeToString(svgEl);
13192      var vb=svgEl.viewBox.baseVal,scale=2;
13193      var headerH=84,footerH=36;
13194      var lw=(vb.width||900),lh=(vb.height||380);
13195      var w=lw*scale,h=(lh+headerH+footerH)*scale;
13196      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
13197      var url=URL.createObjectURL(blob);
13198      var img=new Image();
13199      var tp=trendTitleParts();
13200      img.onload=function(){{
13201        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
13202        var ctx=canvas.getContext('2d');
13203        var cs=getComputedStyle(document.body);
13204        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
13205        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
13206        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
13207        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
13208        ctx.scale(scale,scale);
13209        ctx.textBaseline='alphabetic';ctx.textAlign='left';
13210        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
13211        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
13212        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
13213        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;
13214        ctx.drawImage(img,0,headerH);
13215        var fy=headerH+lh;
13216        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;
13217        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
13218        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);
13219        ctx.textAlign='left';
13220        URL.revokeObjectURL(url);
13221        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
13222      }};
13223      img.src=url;
13224    }}
13225
13226    function exportPDF(){{
13227      var svgEl=document.querySelector('#chart-wrap svg');
13228      if(!svgEl){{alert('No chart to export yet.');return;}}
13229      var tp=trendTitleParts();
13230      var svgStr=new XMLSerializer().serializeToString(svgEl);
13231      var statsEl=document.getElementById('trend-stats');
13232      var statsHtml=statsEl?statsEl.innerHTML:'';
13233      var yK=document.getElementById('y-sel').value;
13234      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13235      var yL=yLabels[yK]||yK;
13236      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13237      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>';
13238      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>';}});
13239      tableHtml+='</tbody></table>';
13240      var css='<style>'
13241        +'*{{box-sizing:border-box;}}'
13242        +'html,body{{margin:0;padding:0;}}'
13243        // Masthead/footer flow in document order — a position:fixed header repeats
13244        // on every printed page in Chromium and hides the rows beneath it on pages
13245        // 2+. The trend table's <thead> repeats per page natively instead.
13246        +'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;}}'
13247        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13248        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13249        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13250        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13251        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13252        +'.rep-body{{padding:22px 34px 0;}}'
13253        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13254        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13255        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13256        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13257        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13258        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13259        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13260        +'.stat-chip-tip{{display:none!important;}}'
13261        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13262        +'.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;}}'
13263        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13264        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13265        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13266        +'.rep-chart svg{{max-width:100%;height:auto;}}'
13267        +'.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;}}'
13268        +'.filter-row{{display:none!important;}}'
13269        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13270        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13271        +'th{{background:#f0e9e0;font-weight:800;}}'
13272        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13273        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13274        +'.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;}}'
13275        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13276        +'</style>';
13277      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13278        +'<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>'
13279        +'<div class="rep-body">'
13280        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13281        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13282        +'<div class="summary-strip">'+statsHtml+'</div>'
13283        +'<div class="rep-chart">'+svgStr+'</div>'
13284        +tableHtml
13285        +'</div>'
13286        +'<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>'
13287        +'</body></html>';
13288      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13289    }}
13290
13291    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13292      var el=document.getElementById(id);
13293      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13294    }});
13295    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13296    // tracks the container like the responsive Chart.js charts do.
13297    var _rsT=null;
13298    window.addEventListener('resize',function(){{
13299      if(_rsT)clearTimeout(_rsT);
13300      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13301    }});
13302    rootSel.addEventListener('change',function(){{
13303      populateSubmodules(rootSel.value);
13304      loadAndRender();
13305    }});
13306    if(subSel)subSel.addEventListener('change',loadAndRender);
13307
13308    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13309    (function(){{
13310      var fvBtn=document.getElementById('tr-chart-fv-btn');
13311      if(!fvBtn)return;
13312      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13313      fvBtn.addEventListener('click',function(){{
13314        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13315        var yKey=document.getElementById('y-sel').value;
13316        var xMode=document.getElementById('x-sel').value;
13317        var pts=allData;
13318        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13319        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13320        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13321        var tp=trendTitleParts();
13322        var ov=document.createElement('div');
13323        ov.className='tr-chart-full-modal';
13324        ov.innerHTML='<div class="tr-chart-full-inner">'
13325          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13326          +'<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>'
13327          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13328          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13329          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13330        document.body.appendChild(ov);
13331        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13332        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13333        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13334        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13335        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13336      }});
13337    }})();
13338
13339    var xlsxBtn=document.getElementById('export-xlsx-btn');
13340    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13341    var pngBtn=document.getElementById('export-png-btn');
13342    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13343    var pdfBtn=document.getElementById('export-pdf-btn');
13344    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13345
13346    // ── Clean-up modal ───────────────────────────────────────────────────────
13347    (function(){{
13348      var triggerBtn=document.getElementById('cleanup-runs-btn');
13349      if(!triggerBtn)return;
13350      var modal=document.createElement('div');
13351      modal.className='tr-modal-backdrop';
13352      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13353        +'<div class="tr-modal-head">'
13354        +'<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>'
13355        +'<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>'
13356        +'</div>'
13357        +'<div class="tr-modal-body">'
13358        +'<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>'
13359        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13360        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13361        +'<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;">'
13362        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13363        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13364        +'</div>'
13365        +'<div class="tr-modal-foot">'
13366        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13367        +'<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>'
13368        +'</div></div>';
13369      document.body.appendChild(modal);
13370      triggerBtn.addEventListener('click',function(){{
13371        document.getElementById('cleanup-status').style.display='none';
13372        modal.style.display='flex';
13373      }});
13374      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13375      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13376      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13377        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13378        var confirmBtn=this;
13379        confirmBtn.disabled=true;
13380        var status=document.getElementById('cleanup-status');
13381        status.style.display='block';
13382        status.style.background='#dbeafe';status.style.color='#1e40af';
13383        status.textContent='Deleting\u2026';
13384        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13385        .then(function(resp){{
13386          return resp.json().then(function(d){{
13387            if(resp.ok){{
13388              status.style.background='#dcfce7';status.style.color='#166534';
13389              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13390              setTimeout(function(){{window.location.reload();}},1500);
13391            }}else{{
13392              status.style.background='#fee2e2';status.style.color='#991b1b';
13393              status.textContent='Error: '+(d.error||'Unexpected error');
13394              confirmBtn.disabled=false;
13395            }}
13396          }});
13397        }})
13398        .catch(function(e){{
13399          status.style.background='#fee2e2';status.style.color='#991b1b';
13400          status.textContent='Network error: '+String(e);
13401          confirmBtn.disabled=false;
13402        }});
13403      }});
13404    }})();
13405
13406    // ── Retention policy panel ────────────────────────────────────────────────
13407    (function(){{
13408      var triggerBtn=document.getElementById('retention-policy-btn');
13409      if(!triggerBtn)return;
13410      var modal=document.createElement('div');
13411      modal.className='tr-modal-backdrop';
13412      modal.style.zIndex='9001';
13413      modal.innerHTML=''
13414        +'<div class="tr-modal" style="max-width:640px;">'
13415        +'<div class="tr-modal-head">'
13416        +'<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>'
13417        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13418        +'</div>'
13419        +'<div class="tr-modal-body">'
13420        +'<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>'
13421        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13422        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13423        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13424        +'</div>'
13425        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13426        +'<div>'
13427        +'<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>'
13428        +'<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;">'
13429        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13430        +'</div>'
13431        +'<div>'
13432        +'<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>'
13433        +'<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;">'
13434        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13435        +'</div>'
13436        +'</div>'
13437        +'<div style="margin-bottom:20px;">'
13438        +'<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>'
13439        +'<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;">'
13440        +'<option value="1">Every hour</option>'
13441        +'<option value="6">Every 6 hours</option>'
13442        +'<option value="12">Every 12 hours</option>'
13443        +'<option value="24" selected>Every 24 hours</option>'
13444        +'<option value="48">Every 2 days</option>'
13445        +'<option value="72">Every 3 days</option>'
13446        +'<option value="168">Every week</option>'
13447        +'</select>'
13448        +'</div>'
13449        +'<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>'
13450        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13451        +'</div>'
13452        +'<div class="tr-modal-foot">'
13453        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13454        +'<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>'
13455        +'<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>'
13456        +'</div>'
13457        +'</div>';
13458      document.body.appendChild(modal);
13459
13460      function rpShowStatus(msg,ok){{
13461        var s=document.getElementById('rp-status');
13462        s.style.display='block';
13463        s.style.background=ok?'#dcfce7':'#fee2e2';
13464        s.style.color=ok?'#166534':'#991b1b';
13465        s.textContent=msg;
13466      }}
13467      function fmtAgo(iso){{
13468        if(!iso)return'Never';
13469        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13470        if(diff<60)return diff+'s ago';
13471        if(diff<3600)return Math.floor(diff/60)+'m ago';
13472        if(diff<86400)return Math.floor(diff/3600)+'h ago';
13473        return Math.floor(diff/86400)+'d ago';
13474      }}
13475      function loadPolicy(){{
13476        fetch('/api/cleanup-policy')
13477          .then(function(r){{return r.json();}})
13478          .then(function(d){{
13479            var p=d.policy;
13480            document.getElementById('rp-enabled').checked=p?p.enabled:false;
13481            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13482            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13483            var sel=document.getElementById('rp-interval');
13484            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;}}}}}}
13485            var lr=document.getElementById('rp-last-run');
13486            if(d.last_run_at){{
13487              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'):'');
13488            }}else{{
13489              lr.textContent='Auto-cleanup has not run yet.';
13490            }}
13491          }})
13492          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13493      }}
13494
13495      triggerBtn.addEventListener('click',function(){{
13496        document.getElementById('rp-status').style.display='none';
13497        loadPolicy();
13498        modal.style.display='flex';
13499      }});
13500      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13501      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13502
13503      document.getElementById('rp-save-btn').addEventListener('click',function(){{
13504        var enabled=document.getElementById('rp-enabled').checked;
13505        var ageVal=document.getElementById('rp-max-age').value.trim();
13506        var countVal=document.getElementById('rp-max-count').value.trim();
13507        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13508        if(enabled&&!ageVal&&!countVal){{
13509          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13510          return;
13511        }}
13512        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13513        var saveBtn=document.getElementById('rp-save-btn');
13514        saveBtn.disabled=true;
13515        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13516          .then(function(r){{
13517            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13518            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13519          }})
13520          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13521          .finally(function(){{saveBtn.disabled=false;}});
13522      }});
13523
13524      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13525        var btn=this;
13526        var orig=btn.innerHTML;
13527        btn.disabled=true;
13528        btn.textContent='Running\u2026';
13529        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13530          .then(function(r){{return r.json();}})
13531          .then(function(d){{
13532            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13533            loadPolicy();
13534          }})
13535          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13536          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13537      }});
13538    }})();
13539
13540    populateSubmodules(rootSel.value);
13541    loadAndRender();
13542
13543    (function randomizeWatermarks() {{
13544      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13545      if (!wms.length) return;
13546      var placed = [];
13547      function tooClose(top, left) {{
13548        for (var i = 0; i < placed.length; i++) {{
13549          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13550          if (dt < 16 && dl < 12) return true;
13551        }}
13552        return false;
13553      }}
13554      function pick(leftBand) {{
13555        for (var attempt = 0; attempt < 50; attempt++) {{
13556          var top = Math.random() * 88 + 2;
13557          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13558          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13559        }}
13560        var top = Math.random() * 88 + 2;
13561        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13562        placed.push([top, left]); return [top, left];
13563      }}
13564      var half = Math.floor(wms.length / 2);
13565      wms.forEach(function (img, i) {{
13566        var pos = pick(i < half);
13567        var size = Math.floor(Math.random() * 100 + 120);
13568        var rot = (Math.random() * 360).toFixed(1);
13569        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13570        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;
13571      }});
13572    }})();
13573    (function spawnCodeParticles() {{
13574      var container = document.getElementById('code-particles');
13575      if (!container) return;
13576      var snippets = [
13577        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13578        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13579        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13580        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13581        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13582      ];
13583      var count = 38;
13584      for (var i = 0; i < count; i++) {{
13585        (function(idx) {{
13586          var el = document.createElement('span');
13587          el.className = 'code-particle';
13588          el.textContent = snippets[idx % snippets.length];
13589          var left = Math.random() * 94 + 2;
13590          var top = Math.random() * 88 + 6;
13591          var dur = (Math.random() * 10 + 9).toFixed(1);
13592          var delay = (Math.random() * 18).toFixed(1);
13593          var rot = (Math.random() * 26 - 13).toFixed(1);
13594          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13595          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13596          container.appendChild(el);
13597        }})(i);
13598      }}
13599    }})();
13600  </script>
13601  <footer class="site-footer">
13602    local code analysis - metrics, history and reports
13603    &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>
13604    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13605    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13606    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13607    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13608  </footer>
13609  <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>
13610  {toast_assets}
13611</body>
13612</html>"##,
13613    );
13614
13615    Html(html).into_response()
13616}
13617
13618fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13619    use std::collections::HashMap;
13620    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13621        return vec![];
13622    }
13623    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13624    for rec in per_file_records {
13625        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13626            let e = totals.entry(lang.display_name().to_string()).or_default();
13627            e.0 += u64::from(cov.lines_found);
13628            e.1 += u64::from(cov.lines_hit);
13629        }
13630    }
13631    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13632    let mut pairs: Vec<(String, f64)> = totals
13633        .into_iter()
13634        .filter(|(_, (found, _))| *found > 0)
13635        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13636        .collect();
13637    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13638    pairs
13639        .iter()
13640        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13641        .collect()
13642}
13643
13644fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13645    let mut high = 0u64;
13646    let mut mid = 0u64;
13647    let mut low = 0u64;
13648    for rec in per_file_records {
13649        if let Some(cov) = &rec.coverage {
13650            if cov.lines_found == 0 {
13651                continue;
13652            }
13653            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13654            if pct >= 80.0 {
13655                high += 1;
13656            } else if pct >= 50.0 {
13657                mid += 1;
13658            } else {
13659                low += 1;
13660            }
13661        }
13662    }
13663    (high, mid, low)
13664}
13665
13666fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13667    let mut arr: Vec<serde_json::Value> = per_file_records
13668        .iter()
13669        .filter_map(|rec| {
13670            rec.coverage.as_ref().map(|cov| {
13671                let line_pct = if cov.lines_found > 0 {
13672                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13673                        / 10.0
13674                } else {
13675                    0.0
13676                };
13677                let fn_pct = if cov.functions_found > 0 {
13678                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13679                        .round()
13680                        / 10.0
13681                } else {
13682                    -1.0
13683                };
13684                serde_json::json!({
13685                    "rel": rec.relative_path,
13686                    "lang": rec.language.map_or("?", |l| l.display_name()),
13687                    "line_pct": line_pct,
13688                    "fn_pct": fn_pct,
13689                    "lhit": cov.lines_hit,
13690                    "lfound": cov.lines_found,
13691                    "fhit": cov.functions_hit,
13692                    "ffound": cov.functions_found,
13693                })
13694            })
13695        })
13696        .collect();
13697    arr.sort_by(|a, b| {
13698        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13699        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13700        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13701    });
13702    arr
13703}
13704
13705#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13706fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13707    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13708        .totals_by_language
13709        .iter()
13710        .filter(|l| l.test_count > 0)
13711        .collect();
13712    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13713    let lang_tests: Vec<serde_json::Value> = langs
13714        .iter()
13715        .map(|l| {
13716            let d = if l.code_lines > 0 {
13717                l.test_count as f64 / l.code_lines as f64 * 1000.0
13718            } else {
13719                0.0
13720            };
13721            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13722                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13723                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13724        })
13725        .collect();
13726    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13727    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13728    let t = &run.summary_totals;
13729    let total_tests = t.test_count;
13730    let density = if t.code_lines > 0 {
13731        total_tests as f64 / t.code_lines as f64 * 1000.0
13732    } else {
13733        0.0
13734    };
13735    let most_tested = langs.first().map_or_else(
13736        || "\u{2014}".to_string(),
13737        |l| l.language.display_name().to_string(),
13738    );
13739    let test_files: u64 = run
13740        .per_file_records
13741        .iter()
13742        .filter(|f| f.raw_line_categories.test_count > 0)
13743        .count() as u64;
13744    let cov_line = if t.coverage_lines_found > 0 {
13745        format!(
13746            "{:.1}",
13747            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13748        )
13749    } else {
13750        "0".to_string()
13751    };
13752    let cov_fn = if t.coverage_functions_found > 0 {
13753        format!(
13754            "{:.1}",
13755            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13756        )
13757    } else {
13758        "0".to_string()
13759    };
13760    let cov_branch = if t.coverage_branches_found > 0 {
13761        format!(
13762            "{:.1}",
13763            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13764        )
13765    } else {
13766        "0".to_string()
13767    };
13768    let has_cov = !cov_arr.is_empty();
13769    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13770    serde_json::json!({
13771        "totals": {
13772            "test_count": total_tests,
13773            "assertions": t.test_assertion_count,
13774            "suites": t.test_suite_count,
13775            "test_files": test_files,
13776            "total_files": t.files_analyzed,
13777            "density_str": format!("{density:.1}"),
13778            "most_tested": most_tested,
13779            "langs_with_tests": langs.len(),
13780            "cov_line": cov_line,
13781            "cov_fn": cov_fn,
13782            "cov_branch": cov_branch,
13783        },
13784        "lang_tests": lang_tests,
13785        "cov": cov_arr,
13786        "cov_tiers": {"high": high, "mid": mid, "low": low},
13787        "file_cov": file_cov_arr,
13788        "has_coverage": has_cov,
13789        "submodules": {},
13790    })
13791}
13792
13793#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13794fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13795    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13796        .language_summaries
13797        .iter()
13798        .filter(|l| l.test_count > 0)
13799        .collect();
13800    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13801    let lang_tests: Vec<serde_json::Value> = langs
13802        .iter()
13803        .map(|l| {
13804            let d = if l.code_lines > 0 {
13805                l.test_count as f64 / l.code_lines as f64 * 1000.0
13806            } else {
13807                0.0
13808            };
13809            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13810                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13811                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13812        })
13813        .collect();
13814    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13815    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13816    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13817    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13818    let density = if sub.code_lines > 0 {
13819        total_tests as f64 / sub.code_lines as f64 * 1000.0
13820    } else {
13821        0.0
13822    };
13823    let most_tested = langs.first().map_or_else(
13824        || "\u{2014}".to_string(),
13825        |l| l.language.display_name().to_string(),
13826    );
13827    serde_json::json!({
13828        "totals": {
13829            "test_count": total_tests,
13830            "assertions": total_assertions,
13831            "suites": total_suites,
13832            "test_files": test_files_approx,
13833            "total_files": sub.files_analyzed,
13834            "density_str": format!("{density:.1}"),
13835            "most_tested": most_tested,
13836            "langs_with_tests": langs.len(),
13837            "cov_line": "0",
13838            "cov_fn": "0",
13839            "cov_branch": "0",
13840        },
13841        "lang_tests": lang_tests,
13842        "cov": [],
13843        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13844        "has_coverage": false,
13845    })
13846}
13847
13848fn compute_cov_json_str(run: &AnalysisRun) -> String {
13849    use std::collections::HashMap;
13850    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13851    for rec in &run.per_file_records {
13852        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13853            let e = totals.entry(lang.display_name().to_string()).or_default();
13854            e.0 += u64::from(cov.lines_found);
13855            e.1 += u64::from(cov.lines_hit);
13856        }
13857    }
13858    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13859    let mut pairs: Vec<(String, f64)> = totals
13860        .into_iter()
13861        .filter(|(_, (found, _))| *found > 0)
13862        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13863        .collect();
13864    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13865    let parts: Vec<String> = pairs
13866        .iter()
13867        .map(|(lang, pct)| {
13868            let name = lang.replace('"', "\\\"");
13869            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13870        })
13871        .collect();
13872    format!("[{}]", parts.join(","))
13873}
13874
13875fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13876    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13877    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13878}
13879
13880fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13881    let mut entry = build_test_scope_entry(run);
13882    if !run.submodule_summaries.is_empty() {
13883        let subs: serde_json::Map<String, serde_json::Value> = run
13884            .submodule_summaries
13885            .iter()
13886            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13887            .collect();
13888        entry["submodules"] = serde_json::Value::Object(subs);
13889    }
13890    entry
13891}
13892
13893fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13894    let name = l.language.display_name().replace('"', "\\\"");
13895    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13896    let density = if l.code_lines > 0 {
13897        l.test_count as f64 / l.code_lines as f64 * 1000.0
13898    } else {
13899        0.0
13900    };
13901    format!(
13902        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13903        name = name,
13904        t = l.test_count,
13905        a = l.test_assertion_count,
13906        s = l.test_suite_count,
13907        c = l.code_lines,
13908        d = density,
13909        f = l.files,
13910    )
13911}
13912
13913fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13914    let Some(r) = run else {
13915        return "[]".to_string();
13916    };
13917    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13918        .totals_by_language
13919        .iter()
13920        .filter(|l| l.test_count > 0)
13921        .collect();
13922    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13923    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13924    format!("[{}]", parts.join(","))
13925}
13926
13927/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13928async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13929    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13930    scope_map.insert(
13931        "__all__".to_string(),
13932        latest_run.map_or_else(
13933            || {
13934                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13935                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13936                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13937                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13938                    "has_coverage":false,"submodules":{}})
13939            },
13940            build_test_scope_entry,
13941        ),
13942    );
13943    let all_roots: Vec<String> = {
13944        let reg = state.registry.lock().await;
13945        let mut seen = std::collections::BTreeSet::new();
13946        reg.entries
13947            .iter()
13948            .flat_map(|e| e.input_roots.iter().cloned())
13949            .filter(|r| seen.insert(r.clone()))
13950            .collect()
13951    };
13952    for root in &all_roots {
13953        let json_path = {
13954            let reg = state.registry.lock().await;
13955            reg.entries
13956                .iter()
13957                .find(|e| e.input_roots.iter().any(|r| r == root))
13958                .and_then(|e| e.json_path.clone())
13959        };
13960        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13961            let json_str = tokio::fs::read_to_string(&p).await.ok();
13962            json_str
13963                .as_deref()
13964                .and_then(|s| serde_json::from_str(s).ok())
13965        } else {
13966            None
13967        };
13968        if let Some(ref run) = run_for_root {
13969            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13970        }
13971    }
13972    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13973}
13974
13975// GET /test-metrics
13976#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13977#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13978async fn test_metrics_handler(
13979    State(state): State<AppState>,
13980    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13981) -> Response {
13982    auto_scan_watched_dirs(&state).await;
13983    let watched_dirs_list: Vec<String> = {
13984        let wd = state.watched_dirs.lock().await;
13985        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13986    };
13987    let latest_run: Option<AnalysisRun> = {
13988        let json_path = {
13989            let reg = state.registry.lock().await;
13990            reg.entries.first().and_then(|e| e.json_path.clone())
13991        };
13992        if let Some(p) = json_path {
13993            let json_str = tokio::fs::read_to_string(&p).await.ok();
13994            json_str
13995                .as_deref()
13996                .and_then(|s| serde_json::from_str(s).ok())
13997        } else {
13998            None
13999        }
14000    };
14001
14002    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
14003    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
14004
14005    // Build coverage chart JSON (per-language avg line coverage %).
14006    let cov_json: String = latest_run
14007        .as_ref()
14008        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14009        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
14010
14011    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
14012    let _cov_tier_json: String = latest_run
14013        .as_ref()
14014        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14015        .map_or_else(
14016            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
14017            compute_cov_tier_json_str,
14018        );
14019
14020    let total_tests: u64 = latest_run
14021        .as_ref()
14022        .map_or(0, |r| r.summary_totals.test_count);
14023    let total_assertions: u64 = latest_run
14024        .as_ref()
14025        .map_or(0, |r| r.summary_totals.test_assertion_count);
14026    let total_suites: u64 = latest_run
14027        .as_ref()
14028        .map_or(0, |r| r.summary_totals.test_suite_count);
14029    let total_code: u64 = latest_run
14030        .as_ref()
14031        .map_or(0, |r| r.summary_totals.code_lines);
14032    let workspace_density: f64 = if total_code > 0 {
14033        total_tests as f64 / total_code as f64 * 1000.0
14034    } else {
14035        0.0
14036    };
14037    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
14038        r.totals_by_language
14039            .iter()
14040            .filter(|l| l.test_count > 0)
14041            .count()
14042    });
14043    let most_tested: String = latest_run
14044        .as_ref()
14045        .and_then(|r| {
14046            r.totals_by_language
14047                .iter()
14048                .filter(|l| l.test_count > 0)
14049                .max_by_key(|l| l.test_count)
14050        })
14051        .map_or_else(
14052            || "\u{2014}".to_string(),
14053            |l| l.language.display_name().to_string(),
14054        );
14055    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
14056        r.per_file_records
14057            .iter()
14058            .filter(|f| f.raw_line_categories.test_count > 0)
14059            .count() as u64
14060    });
14061    let total_files_analyzed: u64 = latest_run
14062        .as_ref()
14063        .map_or(0, |r| r.summary_totals.files_analyzed);
14064    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
14065
14066    // Aggregated coverage percentages from summary_totals
14067    let cov_line_pct_str: String = latest_run
14068        .as_ref()
14069        .filter(|r| r.summary_totals.coverage_lines_found > 0)
14070        .map_or_else(
14071            || "0".to_string(),
14072            |r| {
14073                format!(
14074                    "{:.1}",
14075                    r.summary_totals.coverage_lines_hit as f64
14076                        / r.summary_totals.coverage_lines_found as f64
14077                        * 100.0
14078                )
14079            },
14080        );
14081    let cov_fn_pct_str: String = latest_run
14082        .as_ref()
14083        .filter(|r| r.summary_totals.coverage_functions_found > 0)
14084        .map_or_else(
14085            || "0".to_string(),
14086            |r| {
14087                format!(
14088                    "{:.1}",
14089                    r.summary_totals.coverage_functions_hit as f64
14090                        / r.summary_totals.coverage_functions_found as f64
14091                        * 100.0
14092                )
14093            },
14094        );
14095    let cov_branch_pct_str: String = latest_run
14096        .as_ref()
14097        .filter(|r| r.summary_totals.coverage_branches_found > 0)
14098        .map_or_else(
14099            || "0".to_string(),
14100            |r| {
14101                format!(
14102                    "{:.1}",
14103                    r.summary_totals.coverage_branches_hit as f64
14104                        / r.summary_totals.coverage_branches_found as f64
14105                        * 100.0
14106                )
14107            },
14108        );
14109
14110    let cov_no_data_notice = if has_coverage {
14111        String::new()
14112    } else {
14113        String::from(
14114            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
14115<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>
14116<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
14117  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
14118  <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>
14119  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14120  <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>
14121  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14122  <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>
14123  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14124  <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>
14125  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14126  <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>
14127</div>
14128<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
14129</div>"#,
14130        )
14131    };
14132
14133    let workspace_density_str = format!("{workspace_density:.1}");
14134    let nonce = &csp_nonce;
14135    let toast_assets = sloc_toast_assets(nonce);
14136    let version = env!("CARGO_PKG_VERSION");
14137
14138    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
14139    // of interactive controls — folder watching is managed by the host administrator.
14140    let watched_dirs_html: String = if state.server_mode {
14141        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()
14142    } else {
14143        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
14144            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
14145                .to_string()
14146        } else {
14147            watched_dirs_list
14148                .iter()
14149                .fold(String::new(), |mut s, d| {
14150                    use std::fmt::Write as _;
14151                    let escaped =
14152                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
14153                    write!(
14154                        s,
14155                        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>"#
14156                    ).expect("write to String is infallible");
14157                    s
14158                })
14159        };
14160        format!(
14161            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>"#
14162        )
14163    };
14164
14165    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
14166    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
14167
14168    let html = format!(
14169        r#"<!doctype html>
14170<html lang="en">
14171<head>
14172  <meta charset="utf-8" />
14173  <meta name="viewport" content="width=device-width, initial-scale=1" />
14174  <title>OxideSLOC | Test Metrics</title>
14175  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14176  <style nonce="{nonce}">
14177    :root {{
14178      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
14179      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14180      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14181      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14182      --info-bg:#eef3ff; --info-text:#4467d8;
14183    }}
14184    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
14185    *{{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;}}
14186    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
14187    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
14188    .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;}}
14189    @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));}}}}
14190    .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);}}
14191    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
14192    .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));}}
14193    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
14194    .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;}}
14195    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
14196    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
14197    @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; }} }}
14198    .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;}}
14199    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
14200    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
14201    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
14202    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
14203    .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;}}
14204    .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;}}
14205    .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;}}
14206    .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);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;}}
14207    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
14208    .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);}}
14209    .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;}}
14210    .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;}}
14211    .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;}}
14212    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
14213    .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;}}
14214    .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);}}
14215    .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;}}
14216    .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;}}
14217    .tz-select:focus{{border-color:var(--oxide);}}
14218    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
14219    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
14220    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
14221    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
14222    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
14223    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
14224    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
14225    .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);}}
14226    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
14227    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
14228    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
14229    .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;}}
14230    .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;}}
14231    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14232    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14233    .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);}}
14234    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14235    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14236    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14237    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14238    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14239    .chart-canvas-wrap{{position:relative;height:280px;}}
14240    .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;}}
14241    .chart-no-data svg{{opacity:0.35;}}
14242    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14243    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14244    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14245    .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;}}
14246    .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;}}
14247    .data-table tr:last-child td{{border-bottom:none;}}
14248    .data-table tbody tr:hover td{{background:var(--surface-2);}}
14249    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14250    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14251    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14252    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14253    .cov-gauge-card{{position:relative;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;}}
14254    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14255    .cov-gauge-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:11px;font-weight:500;line-height:1.55;white-space:normal;max-width:300px;min-width:180px;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 14px rgba(0,0,0,0.2);}}
14256    .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14257    .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14258    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14259    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14260    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14261    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14262    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14263    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14264    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14265    .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;}}
14266    .chart-select:focus{{border-color:var(--accent);}}
14267    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14268    .trend-canvas-wrap{{position:relative;height:260px;}}
14269    .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;}}
14270    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14271    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14272    .site-footer a{{color:var(--muted);}}
14273    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14274    .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;}}
14275    .btn:hover{{background:var(--surface-2);}}
14276    .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;}}
14277    .export-btn:hover{{background:var(--line);}}
14278    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14279    /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14280    .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14281    .scope-export{{margin-left:auto;}}
14282    body.pdf-mode .export-group{{display:none!important;}}
14283    @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14284    .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;}}
14285    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14286    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14287    .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;}}
14288    .scope-sel:focus{{border-color:var(--accent);}}
14289    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14290    .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;}}
14291    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14292    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14293    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14294    .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;}}
14295    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14296    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14297    .watched-chip-rm:hover{{color:var(--oxide);}}
14298    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14299    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14300    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14301    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14302    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14303    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14304    .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;}}
14305    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14306    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14307    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14308    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14309    .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;}}
14310    .cov-file-search:focus{{border-color:var(--accent);}}
14311    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14312    .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;}}
14313    body.dark-theme .cov-file-search{{background:var(--surface);}}
14314    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14315    .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;}}
14316    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14317    .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;}}
14318    .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);}}
14319    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14320    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14321    .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;}}
14322    .chart-modal-close:hover{{opacity:.7;}}
14323    body.dark-theme .chart-modal{{background:var(--surface);}}
14324  </style>
14325</head>
14326<body>
14327  <div class="background-watermarks" aria-hidden="true">
14328    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14329    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14330    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14331    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14332    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14333    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14334  </div>
14335  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14336  <div class="top-nav">
14337    <div class="top-nav-inner">
14338      <a class="brand" href="/">
14339        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14340        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14341      </a>
14342      <div class="nav-right">
14343        <a class="nav-pill" href="/">Home</a>
14344        <div class="nav-dropdown">
14345          <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>
14346          <div class="nav-dropdown-menu">
14347            <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>
14348          </div>
14349        </div>
14350        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14351        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14352        <div class="nav-dropdown">
14353          <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>
14354          <div class="nav-dropdown-menu">
14355            <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>
14356          </div>
14357        </div>
14358        <div class="server-status-wrap" id="server-status-wrap">
14359          <div class="nav-pill server-online-pill" id="server-status-pill">
14360            <span class="status-dot" id="status-dot"></span>
14361            <span id="server-status-label">Server</span>
14362            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14363          </div>
14364          <div class="server-status-tip">
14365            OxideSLOC is running — accessible on your network.
14366            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14367          </div>
14368        </div>
14369        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14370          <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>
14371        </button>
14372        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14373          <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>
14374          <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>
14375        </button>
14376      </div>
14377    </div>
14378  </div>
14379
14380  <div class="page">
14381    {watched_dirs_html}
14382    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14383      <div class="scan-overlay-card">
14384        <div class="scan-spinner"></div>
14385        <div class="scan-overlay-text">Scanning folder…</div>
14386        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14387      </div>
14388    </div>
14389    <style>
14390    .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
14391    .scan-overlay.active{{display:flex;}}
14392    .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
14393    .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
14394    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14395    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14396    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14397    </style>
14398    <div class="scope-bar">
14399      <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>
14400      <span class="scope-label">Scope</span>
14401      <div class="scope-sel-wrap">
14402        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14403        <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);">
14404          <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>
14405          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14406        </div>
14407      </div>
14408      <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14409      <div class="export-group scope-export" id="tm-export-group">
14410        <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download the whole page (Test Metrics + LCOV Coverage Summary) as an Excel workbook (.xlsx)">
14411          <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>
14412          Export Excel
14413        </button>
14414        <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14415          <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>
14416          Export PNG
14417        </button>
14418        <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14419          <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>
14420          Export PDF
14421        </button>
14422      </div>
14423    </div>
14424    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14425      <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>
14426      <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>
14427      <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>
14428      <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>
14429    </div>
14430    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14431      <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>
14432      <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>
14433      <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>
14434      <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>
14435    </div>
14436
14437    <div class="panel" id="viz-panel">
14438      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14439
14440      <div class="chart-box" style="margin-bottom:18px;">
14441        <div class="chart-box-header">
14442          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14443          <div style="display:flex;gap:8px;align-items:center;">
14444            <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>
14445            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14446          </div>
14447        </div>
14448        <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>
14449        <div class="trend-controls-bar">
14450          <label>Y Metric:
14451            <select class="chart-select" id="tm-trend-y">
14452              <option value="test_count" selected>Test Definitions</option>
14453              <option value="code_lines">Code Lines</option>
14454            </select>
14455          </label>
14456          <label>X Axis:
14457            <select class="chart-select" id="tm-trend-x">
14458              <option value="commit" selected>By Commit</option>
14459              <option value="time">By Time</option>
14460            </select>
14461          </label>
14462          <label id="tm-sub-label" style="display:none;">Submodule:
14463            <select class="chart-select" id="tm-trend-sub">
14464              <option value="">All (project total)</option>
14465            </select>
14466          </label>
14467          <label>Chart Size:
14468            <select class="chart-select" id="tm-trend-size">
14469              <option value="200">Compact</option>
14470              <option value="260" selected>Normal</option>
14471              <option value="360">Large</option>
14472            </select>
14473          </label>
14474        </div>
14475        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14476        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14477      </div>
14478
14479      <div class="chart-row">
14480        <div class="chart-box">
14481          <div class="chart-box-header">
14482            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14483            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14484          </div>
14485          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14486          <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>
14487        </div>
14488        <div class="chart-box">
14489          <div class="chart-box-header">
14490            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14491            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14492          </div>
14493          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14494          <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>
14495        </div>
14496      </div>
14497
14498      <div class="chart-row">
14499        <div class="chart-box">
14500          <div class="chart-box-header">
14501            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14502            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14503          </div>
14504          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14505          <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>
14506        </div>
14507        <div class="chart-box" id="suites-chart-box">
14508          <div class="chart-box-header">
14509            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14510            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14511          </div>
14512          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14513          <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>
14514        </div>
14515      </div>
14516
14517      <div class="chart-row">
14518        <div class="chart-box">
14519          <div class="chart-box-title">Test Files Breakdown</div>
14520          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14521          <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>
14522        </div>
14523        <div class="chart-box">
14524          <div class="chart-box-title">Test Composition</div>
14525          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14526          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14527          <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>
14528        </div>
14529      </div>
14530    </div>
14531
14532    <div class="panel">
14533      <h1>Test Metrics</h1>
14534      <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>
14535
14536      <div class="section-header">Language Breakdown</div>
14537      {cov_no_data_notice}
14538      <div style="overflow-x:auto;">
14539        <table class="data-table" id="lang-table">
14540          <thead><tr>
14541            <th>Language</th>
14542            <th class="num">Test Fns</th>
14543            <th class="num">Assertions</th>
14544            <th class="num">Suites</th>
14545            <th class="num">Code Lines</th>
14546            <th class="num">Files</th>
14547            <th class="num">Density / 1K</th>
14548            <th>Relative Density</th>
14549          </tr></thead>
14550          <tbody id="lang-tbody"></tbody>
14551        </table>
14552      </div>
14553    </div>
14554
14555    <div class="panel" id="cov-panel" style="display:none;">
14556      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14557      <div class="cov-gauge-row" id="cov-gauges">
14558        <div class="cov-gauge-card">
14559          <div class="cov-gauge-label">Line Coverage</div>
14560          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14561          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14562          <div class="cov-gauge-sub">Lines hit / instrumented</div>
14563          <div class="cov-gauge-tip">Percentage of executable lines exercised by the test suite (lines hit &divide; lines instrumented), aggregated across every file in the LCOV report.</div>
14564        </div>
14565        <div class="cov-gauge-card">
14566          <div class="cov-gauge-label">Function Coverage</div>
14567          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14568          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14569          <div class="cov-gauge-sub">Functions hit / found</div>
14570          <div class="cov-gauge-tip">Percentage of functions called at least once during testing (functions hit &divide; functions found). Shows 0% when the coverage report carries no function-level (FN/FNH) records.</div>
14571        </div>
14572        <div class="cov-gauge-card">
14573          <div class="cov-gauge-label">Branch Coverage</div>
14574          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14575          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14576          <div class="cov-gauge-sub">Branches hit / found</div>
14577          <div class="cov-gauge-tip">Percentage of conditional branches taken during testing (branches hit &divide; branches found). Shows 0% when the coverage report carries no branch-level (BRDA/BRF) records.</div>
14578        </div>
14579      </div>
14580      <div class="chart-row">
14581        <div class="chart-box">
14582          <div class="chart-box-title">Line Coverage % by Language</div>
14583          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14584        </div>
14585        <div class="chart-box">
14586          <div class="chart-box-title">Coverage Tier Distribution</div>
14587          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14588        </div>
14589      </div>
14590
14591      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14592      <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>
14593      <div class="cov-file-toolbar">
14594        <div class="cov-filter-tabs" id="cov-filter-tabs">
14595          <button class="cov-tab active" data-tier="all">All</button>
14596          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14597          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
14598          <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
14599          <button class="cov-tab" data-tier="high">High (≥80%)</button>
14600        </div>
14601        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14602      </div>
14603      <div style="overflow-x:auto;">
14604        <table class="data-table" id="cov-file-table">
14605          <thead><tr>
14606            <th>File</th>
14607            <th>Lang</th>
14608            <th class="num">Line %</th>
14609            <th class="num">Lines Hit / Found</th>
14610            <th class="num">Fn %</th>
14611            <th class="num">Fns Hit / Found</th>
14612          </tr></thead>
14613          <tbody id="cov-file-tbody"></tbody>
14614        </table>
14615      </div>
14616      <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>
14617      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14618    </div>
14619
14620  </div>
14621
14622  <footer class="site-footer">
14623    local code analysis - metrics, history and reports
14624    &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>
14625    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14626    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14627    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14628    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14629  </footer>
14630
14631  <script nonce="{nonce}">
14632  (function() {{
14633    // Theme
14634    var b = document.body;
14635    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14636    var tgl = document.getElementById('theme-toggle');
14637    if (tgl) tgl.addEventListener('click', function() {{
14638      var d = b.classList.toggle('dark-theme');
14639      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14640    }});
14641
14642    // Watermarks
14643    (function() {{
14644      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14645      if (!wms.length) return;
14646      var placed = [];
14647      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;}}
14648      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];}}
14649      var half=Math.floor(wms.length/2);
14650      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;}});
14651    }})();
14652
14653    // Code particles
14654    (function() {{
14655      var container = document.getElementById('code-particles');
14656      if (!container) return;
14657      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14658      for (var i = 0; i < 36; i++) {{
14659        (function(idx) {{
14660          var el = document.createElement('span');
14661          el.className = 'code-particle';
14662          el.textContent = snippets[idx % snippets.length];
14663          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14664          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14665          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14666          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';
14667          container.appendChild(el);
14668        }})(i);
14669      }}
14670    }})();
14671
14672    // Settings modal
14673    (function() {{
14674      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'}}];
14675      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);}});}}
14676      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14677      var btn=document.getElementById('settings-btn');if(!btn)return;
14678      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14679      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>';
14680      document.body.appendChild(m);
14681      var g=document.getElementById('scheme-grid');
14682      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);}});
14683      var cl=document.getElementById('settings-close');
14684      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');}});
14685      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14686      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14687    }})();
14688
14689    // Watched folder picker
14690    (function(){{
14691      window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
14692      document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
14693    }})();
14694    (function() {{
14695      var btn = document.getElementById('add-watched-btn');
14696      if (!btn) return;
14697      btn.addEventListener('click', function() {{
14698        fetch('/pick-directory?kind=reports')
14699          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14700          .then(function(data) {{
14701            if (!data.cancelled && data.selected_path) {{
14702              var form = document.createElement('form');
14703              form.method = 'POST';
14704              form.action = '/watched-dirs/add';
14705              var ri = document.createElement('input');
14706              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14707              var fi = document.createElement('input');
14708              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14709              form.appendChild(ri); form.appendChild(fi);
14710              document.body.appendChild(form);
14711              if (window.__scanOverlay) window.__scanOverlay();
14712              form.submit();
14713            }}
14714          }})
14715          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14716      }});
14717    }})();
14718  }})();
14719  </script>
14720
14721  <script src="/static/chart.js" nonce="{nonce}"></script>
14722  <script nonce="{nonce}">
14723  (function() {{
14724    var SCOPE_DATA = {scope_data_json};
14725    var currentRoot = '__all__';
14726    var currentSub  = '';
14727    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14728    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14729    var ALL_CHARTS = [];
14730    var currentLangTests = [];
14731    var currentTrendPts = [];
14732
14733    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();}}
14734    function fmtFull(n){{return Number(n).toLocaleString();}}
14735    function isDark(){{return document.body.classList.contains('dark-theme');}}
14736    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14737    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14738    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14739
14740    function makeDlPlugin(fmtFn, anchor) {{
14741      return {{
14742        afterDatasetsDraw: function(chart) {{
14743          var ctx = chart.ctx;
14744          var tc = txtClr();
14745          chart.data.datasets.forEach(function(ds, di) {{
14746            var meta = chart.getDatasetMeta(di);
14747            meta.data.forEach(function(el, idx) {{
14748              var label = fmtFn(ds.data[idx], di, idx);
14749              if (label == null || label === '') return;
14750              ctx.save();
14751              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14752              ctx.fillStyle = tc;
14753              if (anchor === 'top') {{
14754                ctx.textAlign = 'center';
14755                ctx.textBaseline = 'bottom';
14756                ctx.fillText(String(label), el.x, el.y - 5);
14757              }} else {{
14758                ctx.textAlign = 'left';
14759                ctx.textBaseline = 'middle';
14760                ctx.fillText(String(label), el.x + 5, el.y);
14761              }}
14762              ctx.restore();
14763            }});
14764          }});
14765        }}
14766      }};
14767    }}
14768
14769    // Cursor: pointer over chart data, default over empty chart area.
14770    function chartCursor(e, els) {{
14771      var t = e.native && e.native.target;
14772      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14773    }}
14774    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14775
14776    // ── Global bar hover emphasis ──────────────────────────────────────────────
14777    // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14778    // *other* bars does nothing when there is only one). Give every bar chart a
14779    // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14780    // animates via the fast active transition. Applied globally through a plugin so
14781    // it covers all current and future bar charts on the page.
14782    function tmLighten(c, amt) {{
14783      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14784        var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14785        r = Math.round(r + (255 - r) * amt);
14786        g = Math.round(g + (255 - g) * amt);
14787        b = Math.round(b + (255 - b) * amt);
14788        return 'rgb(' + r + ',' + g + ',' + b + ')';
14789      }}
14790      return c;
14791    }}
14792    var tmBarHoverEmphasis = {{
14793      id: 'tmBarHoverEmphasis',
14794      beforeInit: function(chart) {{
14795        if (!chart.config || chart.config.type !== 'bar') return;
14796        (chart.data.datasets || []).forEach(function(ds) {{
14797          var bg = ds.backgroundColor;
14798          if (ds.hoverBackgroundColor == null) {{
14799            ds.hoverBackgroundColor = Array.isArray(bg)
14800              ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14801              : tmLighten(bg, 0.24);
14802          }}
14803          if (ds.hoverBorderColor == null) {{
14804            ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14805          }}
14806          if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14807        }});
14808      }}
14809    }};
14810    Chart.register(tmBarHoverEmphasis);
14811    // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14812    try {{
14813      Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14814      Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14815      Chart.defaults.transitions.active.animation.duration = 260;
14816    }} catch (e) {{}}
14817
14818    // Plugin: draws % labels inside each doughnut slice.
14819    var donutPctPlugin = {{
14820      afterDatasetsDraw: function(chart) {{
14821        var ctx = chart.ctx;
14822        chart.data.datasets.forEach(function(ds, di) {{
14823          var meta = chart.getDatasetMeta(di);
14824          if (meta.hidden) return;
14825          var total = 0;
14826          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14827          if (!total) return;
14828          meta.data.forEach(function(arc, i) {{
14829            if (arc.hidden) return;
14830            var val = ds.data[i] || 0;
14831            var pct = val / total * 100;
14832            if (pct < 3) return;
14833            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14834            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14835            var tx = arc.x + midR * Math.cos(midAngle);
14836            var ty = arc.y + midR * Math.sin(midAngle);
14837            ctx.save();
14838            ctx.textAlign = 'center';
14839            ctx.textBaseline = 'middle';
14840            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14841            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14842            ctx.shadowBlur = 3;
14843            ctx.fillStyle = '#fff';
14844            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14845            ctx.restore();
14846          }});
14847        }});
14848      }}
14849    }};
14850
14851    function makeTmOverlay(title, subtitle, h) {{
14852      var overlay = document.createElement('div');
14853      overlay.className = 'chart-modal-overlay';
14854      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14855      var ch = Math.min(h || 560, maxH);
14856      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14857      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>';
14858      document.body.appendChild(overlay);
14859      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14860      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14861      return document.getElementById('tm-modal-canvas');
14862    }}
14863
14864    function getDataset() {{
14865      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14866      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14867      return r;
14868    }}
14869    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14870
14871    function showNoData(id, show) {{
14872      var el = document.getElementById(id);
14873      if (!el) return;
14874      var wrap = el.previousElementSibling;
14875      el.style.display = show ? '' : 'none';
14876      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14877    }}
14878
14879    // Shared hover treatment for every single-series bar/doughnut chart on this page:
14880    // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14881    // treatment used by the language charts on the scan results page.
14882    function tmFadeColor(c) {{
14883      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14884      return c;
14885    }}
14886    function tmApplyFade(chart, activeIdx) {{
14887      var ds = chart.data.datasets[0];
14888      if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14889      if (activeIdx == null) {{
14890        ds.backgroundColor = ds._baseBg.slice();
14891      }} else {{
14892        ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14893          return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14894        }});
14895      }}
14896    }}
14897    function tmFadeHover(e, active, chart) {{
14898      var t = e.native && e.native.target;
14899      if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14900      var idx = active.length ? active[0].index : null;
14901      if (chart._fadeIdx === idx) return;
14902      chart._fadeIdx = idx;
14903      tmApplyFade(chart, idx);
14904      // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14905      // transition (doughnuts keep their own hoverOffset motion regardless).
14906      chart.update('active');
14907    }}
14908    // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14909    function tmDoughnutLegendHover(e, item, leg) {{
14910      var ch = leg.chart;
14911      var t = e.native && e.native.target;
14912      if (t) t.style.cursor = 'pointer';
14913      ch._fadeIdx = item.index;
14914      ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14915      ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14916      tmApplyFade(ch, item.index);
14917      ch.update();
14918    }}
14919    function tmDoughnutLegendLeave(e, item, leg) {{
14920      var ch = leg.chart;
14921      var t = e.native && e.native.target;
14922      if (t) t.style.cursor = 'default';
14923      ch._fadeIdx = null;
14924      ch.setActiveElements([]);
14925      ch.tooltip.setActiveElements([], {{}});
14926      tmApplyFade(ch, null);
14927      ch.update('none');
14928    }}
14929
14930    function renderTestCharts(D) {{
14931      currentLangTests = D || [];
14932      testsChart = destroyChart(testsChart);
14933      densityChart = destroyChart(densityChart);
14934      if (!D || !D.length) {{
14935        showNoData('no-data-tests', true);
14936        showNoData('no-data-density', true);
14937        return;
14938      }}
14939      showNoData('no-data-tests', false);
14940      showNoData('no-data-density', false);
14941      var top15 = D.slice(0, 15);
14942      var canvas1 = document.getElementById('canvas-tests');
14943      if (canvas1) {{
14944        testsChart = new Chart(canvas1, {{
14945          type: 'bar',
14946          data: {{
14947            labels: top15.map(function(d){{ return d.lang; }}),
14948            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14949          }},
14950          options: {{
14951            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14952            layout: {{ padding: {{ right: 64 }} }},
14953            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14954            scales: {{
14955              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14956              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14957            }}
14958          }},
14959          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14960        }});
14961        ALL_CHARTS.push(testsChart);
14962      }}
14963      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14964      var canvas2 = document.getElementById('canvas-density');
14965      if (canvas2) {{
14966        densityChart = new Chart(canvas2, {{
14967          type: 'bar',
14968          data: {{
14969            labels: topD.map(function(d){{ return d.lang; }}),
14970            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 }}]
14971          }},
14972          options: {{
14973            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14974            layout: {{ padding: {{ right: 64 }} }},
14975            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14976            scales: {{
14977              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14978              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14979            }}
14980          }},
14981          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14982        }});
14983        ALL_CHARTS.push(densityChart);
14984      }}
14985    }}
14986
14987    function renderAssertionsChart(D) {{
14988      assertionsChart = destroyChart(assertionsChart);
14989      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14990      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14991      var canvas = document.getElementById('canvas-assertions');
14992      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14993      showNoData('no-data-assertions', false);
14994      assertionsChart = new Chart(canvas, {{
14995        type: 'bar',
14996        data: {{
14997          labels: top15.map(function(d){{ return d.lang; }}),
14998          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14999        }},
15000        options: {{
15001          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15002          layout: {{ padding: {{ right: 64 }} }},
15003          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15004          scales: {{
15005            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15006            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15007          }}
15008        }},
15009        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15010      }});
15011      ALL_CHARTS.push(assertionsChart);
15012    }}
15013
15014    function renderSuitesChart(D) {{
15015      suitesChart = destroyChart(suitesChart);
15016      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
15017      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15018      var canvas = document.getElementById('canvas-suites');
15019      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
15020      showNoData('no-data-suites', false);
15021      suitesChart = new Chart(canvas, {{
15022        type: 'bar',
15023        data: {{
15024          labels: top15.map(function(d){{ return d.lang; }}),
15025          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 }}]
15026        }},
15027        options: {{
15028          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15029          layout: {{ padding: {{ right: 64 }} }},
15030          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15031          scales: {{
15032            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15033            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15034          }}
15035        }},
15036        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15037      }});
15038      ALL_CHARTS.push(suitesChart);
15039    }}
15040
15041    function renderFilesChart(totals) {{
15042      filesChart = destroyChart(filesChart);
15043      var canvas = document.getElementById('canvas-files');
15044      if (!canvas) return;
15045      var testF = totals.test_files || 0;
15046      var totalF = totals.total_files || 0;
15047      var nonTest = Math.max(0, totalF - testF);
15048      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
15049      showNoData('no-data-files', false);
15050      var dark = isDark();
15051      filesChart = new Chart(canvas, {{
15052        type: 'doughnut',
15053        data: {{
15054          labels: ['Test Files', 'Non-Test Files'],
15055          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15056        }},
15057        options: {{
15058          responsive: true, maintainAspectRatio: false, cutout: '62%',
15059          onHover: tmFadeHover,
15060          plugins: {{
15061            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
15062              generateLabels: function(chart) {{
15063                var ds = chart.data.datasets[0];
15064                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
15065                return chart.data.labels.map(function(lbl, i) {{
15066                  var val = ds.data[i] || 0;
15067                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
15068                  return {{
15069                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
15070                    fillStyle: ds.backgroundColor[i],
15071                    strokeStyle: ds.borderColor,
15072                    lineWidth: ds.borderWidth,
15073                    hidden: false,
15074                    index: i,
15075                    datasetIndex: 0
15076                  }};
15077                }});
15078              }}
15079            }},
15080              onHover: tmDoughnutLegendHover,
15081              onLeave: tmDoughnutLegendLeave
15082            }},
15083            tooltip: {{ callbacks: {{ label: function(ctx) {{
15084              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
15085              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
15086            }} }} }}
15087          }}
15088        }},
15089        plugins: [donutPctPlugin]
15090      }});
15091      ALL_CHARTS.push(filesChart);
15092    }}
15093
15094    function renderCompositionChart(totals) {{
15095      compositionChart = destroyChart(compositionChart);
15096      var canvas = document.getElementById('canvas-composition');
15097      if (!canvas) return;
15098      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
15099      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
15100      showNoData('no-data-composition', false);
15101      compositionChart = new Chart(canvas, {{
15102        type: 'bar',
15103        data: {{
15104          labels: ['Test Functions', 'Assertions', 'Test Suites'],
15105          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
15106        }},
15107        options: {{
15108          responsive: true, maintainAspectRatio: false,
15109          onHover: tmFadeHover,
15110          layout: {{ padding: {{ top: 22 }} }},
15111          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
15112          scales: {{
15113            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
15114            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15115          }}
15116        }},
15117        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
15118      }});
15119      ALL_CHARTS.push(compositionChart);
15120    }}
15121
15122    function renderCovCharts(covD, tiers) {{
15123      covChart = destroyChart(covChart);
15124      tierChart = destroyChart(tierChart);
15125      var covCanvas = document.getElementById('canvas-cov');
15126      if (covCanvas && covD && covD.length) {{
15127        covChart = new Chart(covCanvas, {{
15128          type: 'bar',
15129          data: {{
15130            labels: covD.map(function(d){{ return d.lang; }}),
15131            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 }}]
15132          }},
15133          options: {{
15134            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15135            layout: {{ padding: {{ right: 52 }} }},
15136            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
15137            scales: {{
15138              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
15139              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15140            }}
15141          }},
15142          plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
15143        }});
15144        ALL_CHARTS.push(covChart);
15145      }}
15146      var tierCanvas = document.getElementById('canvas-cov-tiers');
15147      if (tierCanvas && tiers) {{
15148        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
15149        tierChart = new Chart(tierCanvas, {{
15150          type: 'doughnut',
15151          data: {{
15152            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
15153            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15154          }},
15155          options: {{
15156            responsive: true, maintainAspectRatio: false, cutout: '62%',
15157            onHover: tmFadeHover,
15158            plugins: {{
15159              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
15160                onHover: tmDoughnutLegendHover,
15161                onLeave: tmDoughnutLegendLeave
15162              }},
15163              tooltip: {{ callbacks: {{ label: function(ctx) {{
15164                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
15165                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
15166              }} }} }}
15167            }}
15168          }},
15169          plugins: [donutPctPlugin]
15170        }});
15171        ALL_CHARTS.push(tierChart);
15172      }}
15173    }}
15174
15175    function buildLangTable(D) {{
15176      var tbody = document.getElementById('lang-tbody');
15177      if (!tbody) return;
15178      if (!D || !D.length) {{
15179        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>';
15180        return;
15181      }}
15182      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
15183      tbody.innerHTML = D.map(function(d) {{
15184        var barW = Math.round(d.density / maxDensity * 120);
15185        return '<tr>' +
15186          '<td><strong>' + d.lang + '</strong></td>' +
15187          '<td class="num">' + fmtFull(d.tests) + '</td>' +
15188          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
15189          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
15190          '<td class="num">' + fmtFull(d.code) + '</td>' +
15191          '<td class="num">' + fmtFull(d.files) + '</td>' +
15192          '<td class="num">' + d.density.toFixed(2) + '</td>' +
15193          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
15194          '</tr>';
15195      }}).join('');
15196    }}
15197
15198    var covFileData = [];
15199    var covFileTier = 'all';
15200    var covFileSearch = '';
15201
15202    function pctBadge(pct) {{
15203      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
15204      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
15205      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
15206    }}
15207
15208    function buildCovFileTable() {{
15209      var tbody = document.getElementById('cov-file-tbody');
15210      var empty = document.getElementById('cov-file-empty');
15211      var count = document.getElementById('cov-file-count');
15212      if (!tbody) return;
15213      var srch = covFileSearch.toLowerCase();
15214      var filtered = covFileData.filter(function(f) {{
15215        if (covFileTier === 'zero' && f.line_pct > 0) return false;
15216        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
15217        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
15218        if (covFileTier === 'high' && f.line_pct < 80) return false;
15219        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
15220        return true;
15221      }});
15222      if (!filtered.length) {{
15223        tbody.innerHTML = '';
15224        if (empty) empty.style.display = '';
15225        if (count) count.textContent = '';
15226        return;
15227      }}
15228      if (empty) empty.style.display = 'none';
15229      var shown = Math.min(filtered.length, 500);
15230      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15231      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15232        var fnCol = f.fn_pct < 0
15233          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15234          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15235        return '<tr>' +
15236          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
15237          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15238          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15239          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15240          fnCol +
15241          '</tr>';
15242      }}).join('');
15243    }}
15244
15245    (function() {{
15246      var tabs = document.getElementById('cov-filter-tabs');
15247      if (tabs) {{
15248        tabs.addEventListener('click', function(e) {{
15249          var btn = e.target.closest('.cov-tab');
15250          if (!btn) return;
15251          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15252          btn.classList.add('active');
15253          covFileTier = btn.getAttribute('data-tier');
15254          buildCovFileTable();
15255        }});
15256      }}
15257      var srch = document.getElementById('cov-file-search');
15258      if (srch) {{
15259        srch.addEventListener('input', function() {{
15260          covFileSearch = this.value;
15261          buildCovFileTable();
15262        }});
15263      }}
15264    }})();
15265
15266    function updateCovGauges(t) {{
15267      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15268      var el;
15269      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15270      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15271      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15272      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15273      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15274      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15275    }}
15276
15277    function applyScope() {{
15278      var d = getDataset();
15279      var t = d.totals;
15280      var el;
15281      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15282      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15283      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15284      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15285      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15286      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15287      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15288      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15289      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15290      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15291      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15292      renderTestCharts(d.lang_tests);
15293      renderAssertionsChart(d.lang_tests);
15294      renderSuitesChart(d.lang_tests);
15295      renderFilesChart(t);
15296      renderCompositionChart(t);
15297      buildLangTable(d.lang_tests);
15298      var covPanel = document.getElementById('cov-panel');
15299      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15300      if (d.has_coverage) {{
15301        renderCovCharts(d.cov, d.cov_tiers);
15302        updateCovGauges(t);
15303        covFileData = d.file_cov || [];
15304        covFileTier = 'all';
15305        covFileSearch = '';
15306        var tabs = document.getElementById('cov-filter-tabs');
15307        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15308        var srch = document.getElementById('cov-file-search');
15309        if (srch) srch.value = '';
15310        buildCovFileTable();
15311      }}
15312      loadTrend();
15313    }}
15314
15315    // Populate scope-root-sel from SCOPE_DATA keys
15316    (function() {{
15317      var sel = document.getElementById('scope-root-sel');
15318      if (!sel) return;
15319      Object.keys(SCOPE_DATA).forEach(function(k) {{
15320        if (k === '__all__') return;
15321        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15322      }});
15323    }})();
15324
15325    document.getElementById('scope-root-sel').addEventListener('change', function() {{
15326      currentRoot = this.value;
15327      currentSub = '';
15328      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15329      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15330      var subWrap = document.getElementById('scope-sub-wrap');
15331      var subSel  = document.getElementById('scope-sub-sel');
15332      subSel.innerHTML = '<option value="">Entire project</option>';
15333      if (subNames.length) {{
15334        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15335        subWrap.style.display = 'flex';
15336      }} else {{
15337        subWrap.style.display = 'none';
15338      }}
15339      applyScope();
15340    }});
15341
15342    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15343      currentSub = this.value;
15344      applyScope();
15345    }});
15346
15347    var allTrendData = [];
15348
15349    var TM_Y_META = {{
15350      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15351      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
15352    }};
15353
15354    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15355    function hexRgb(hex) {{
15356      var h = String(hex).replace('#', '');
15357      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15358      var n = parseInt(h, 16);
15359      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15360    }}
15361    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15362    // tint at the top to transparent at the bottom (no flat solid block).
15363    function tmTrendGradient(ctx2, chartArea, color) {{
15364      var rgb = hexRgb(color);
15365      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15366      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
15367      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15368      g.addColorStop(1,   'rgba(' + rgb + ',0)');
15369      return g;
15370    }}
15371
15372    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15373    // so linear interpolation between adjacent points matches the drawn line).
15374    function tmLineYAt(chart, px) {{
15375      var meta = chart.getDatasetMeta(0);
15376      if (!meta || !meta.data || !meta.data.length) return null;
15377      var d = meta.data;
15378      if (px <= d[0].x) return d[0].y;
15379      for (var i = 1; i < d.length; i++) {{
15380        if (px <= d[i].x) {{
15381          var span = d[i].x - d[i - 1].x;
15382          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15383          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15384        }}
15385      }}
15386      return d[d.length - 1].y;
15387    }}
15388
15389    // Plugin: only show the tooltip / finger cursor when the pointer is over the
15390    // gradient fill (inside the plot and at/below the line) — never in the empty
15391    // space above the line. Outside the fill we retype the event as 'mouseout' so
15392    // the core interaction dismisses any active tooltip on its own.
15393    var tmFillGuard = {{
15394      id: 'tmFillGuard',
15395      beforeEvent: function(chart, args) {{
15396        var e = args.event;
15397        if (!e || e.type !== 'mousemove') return;
15398        var ca = chart.chartArea;
15399        if (!ca) return;
15400        var inFill = false;
15401        if (e.x >= ca.left && e.x <= ca.right) {{
15402          var ly = tmLineYAt(chart, e.x);
15403          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15404        }}
15405        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15406        if (!inFill) {{ e.type = 'mouseout'; }}
15407      }}
15408    }};
15409
15410    // Single source of truth for the test-metrics trend chart config so the inline
15411    // chart and the Full View modal render identically (straight segments, gradient
15412    // fill, white-ringed points, gradient-only interactivity).
15413    function buildTmTrendConfig(pts, ctrl, meta) {{
15414      return {{
15415        type: 'line',
15416        data: {{
15417          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15418          datasets: [{{
15419            label: meta.label,
15420            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15421            borderColor: meta.color,
15422            borderWidth: 2.5,
15423            backgroundColor: function(context) {{
15424              var ca = context.chart.chartArea;
15425              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15426              return tmTrendGradient(context.chart.ctx, ca, meta.color);
15427            }},
15428            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15429            pointBorderColor: '#fff',
15430            pointBorderWidth: 2,
15431            pointRadius: 6,
15432            pointHoverRadius: 9,
15433            pointHoverBorderWidth: 2.5,
15434            fill: true, tension: 0
15435          }}]
15436        }},
15437        options: {{
15438          responsive: true, maintainAspectRatio: false,
15439          layout: {{ padding: {{ top: 22 }} }},
15440          interaction: {{ mode: 'index', intersect: false }},
15441          plugins: {{
15442            legend: {{ display: false }},
15443            tooltip: {{
15444              mode: 'index', intersect: false,
15445              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15446            }}
15447          }},
15448          scales: {{
15449            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15450            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15451          }}
15452        }},
15453        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15454      }};
15455    }}
15456
15457    function getTrendControls() {{
15458      var ySel    = document.getElementById('tm-trend-y');
15459      var xSel    = document.getElementById('tm-trend-x');
15460      var sizeSel = document.getElementById('tm-trend-size');
15461      var subSel  = document.getElementById('tm-trend-sub');
15462      return {{
15463        yKey:    ySel    ? ySel.value    : 'test_count',
15464        xMode:   xSel    ? xSel.value    : 'commit',
15465        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
15466        submod:  subSel  ? subSel.value  : ''
15467      }};
15468    }}
15469
15470    function makeTrendLabel(d, xMode) {{
15471      if (xMode === 'commit') {{
15472        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15473      }}
15474      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15475    }}
15476
15477    function buildTrend(data) {{
15478      allTrendData = data || [];
15479      renderTrend();
15480    }}
15481
15482    function renderTrend() {{
15483      var data = allTrendData;
15484      var ctrl = getTrendControls();
15485      var trendCanvas = document.getElementById('canvas-trend');
15486      var trendWrap   = document.getElementById('trend-canvas-wrap');
15487      var trendEmpty  = document.getElementById('trend-empty');
15488
15489      // Apply chart size
15490      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15491
15492      // Filter by submodule if selected (entries from project_label match)
15493      var pts = data.slice().reverse();
15494      if (ctrl.submod) {{
15495        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15496      }}
15497
15498      currentTrendPts = pts;
15499
15500      if (!pts.length) {{
15501        if (trendCanvas) trendCanvas.style.display = 'none';
15502        if (trendEmpty) trendEmpty.style.display = '';
15503        return;
15504      }}
15505      if (trendCanvas) trendCanvas.style.display = '';
15506      if (trendEmpty) trendEmpty.style.display = 'none';
15507
15508      trendChart = destroyChart(trendChart);
15509      if (!trendCanvas) return;
15510
15511      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15512
15513      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15514      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15515      ALL_CHARTS.push(trendChart);
15516
15517      // Populate submodule selector from unique project_labels
15518      var subSel = document.getElementById('tm-trend-sub');
15519      var subLabel = document.getElementById('tm-sub-label');
15520      if (subSel && data.length) {{
15521        var projects = [];
15522        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15523        if (projects.length > 1) {{
15524          var curVal = subSel.value;
15525          subSel.innerHTML = '<option value="">All (project total)</option>';
15526          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15527          if (subLabel) subLabel.style.display = '';
15528        }} else {{
15529          if (subLabel) subLabel.style.display = 'none';
15530        }}
15531      }}
15532    }}
15533
15534    // ── Full View expand buttons ──────────────────────────────────────────────
15535    (function() {{
15536      var btn = document.getElementById('tests-expand-btn');
15537      if (!btn) return;
15538      btn.addEventListener('click', function() {{
15539        var D = currentLangTests;
15540        if (!D || !D.length) return;
15541        var top15 = D.slice(0, 15);
15542        var h = Math.max(320, top15.length * 36 + 80);
15543        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15544        if (!canvas) return;
15545        new Chart(canvas, {{
15546          type: 'bar',
15547          data: {{
15548            labels: top15.map(function(d){{ return d.lang; }}),
15549            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15550          }},
15551          options: {{
15552            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15553            layout: {{ padding: {{ right: 72 }} }},
15554            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15555            scales: {{
15556              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15557              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15558            }}
15559          }},
15560          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15561        }});
15562      }});
15563    }})();
15564
15565    (function() {{
15566      var btn = document.getElementById('density-expand-btn');
15567      if (!btn) return;
15568      btn.addEventListener('click', function() {{
15569        var D = currentLangTests;
15570        if (!D || !D.length) return;
15571        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15572        var h = Math.max(320, topD.length * 36 + 80);
15573        var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15574        if (!canvas) return;
15575        new Chart(canvas, {{
15576          type: 'bar',
15577          data: {{
15578            labels: topD.map(function(d){{ return d.lang; }}),
15579            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 }}]
15580          }},
15581          options: {{
15582            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15583            layout: {{ padding: {{ right: 72 }} }},
15584            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15585            scales: {{
15586              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15587              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15588            }}
15589          }},
15590          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15591        }});
15592      }});
15593    }})();
15594
15595    (function() {{
15596      var btn = document.getElementById('trend-expand-btn');
15597      if (!btn) return;
15598      btn.addEventListener('click', function() {{
15599        var pts = currentTrendPts;
15600        if (!pts || !pts.length) return;
15601        var ctrl = getTrendControls();
15602        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15603        var title = meta.label + ' Trend \u2014 Full View';
15604        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15605        if (!canvas) return;
15606        // Reuse the exact inline-chart config so Full View matches the default view
15607        // (straight segments + gradient-only interactivity), just larger.
15608        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15609      }});
15610    }})();
15611
15612    (function() {{
15613      var btn = document.getElementById('assertions-expand-btn');
15614      if (!btn) return;
15615      btn.addEventListener('click', function() {{
15616        var D = currentLangTests;
15617        if (!D || !D.length) return;
15618        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15619        if (!top15.length) return;
15620        var h = Math.max(320, top15.length * 36 + 80);
15621        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15622        if (!canvas) return;
15623        new Chart(canvas, {{
15624          type: 'bar',
15625          data: {{
15626            labels: top15.map(function(d){{ return d.lang; }}),
15627            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15628          }},
15629          options: {{
15630            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15631            layout: {{ padding: {{ right: 72 }} }},
15632            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15633            scales: {{
15634              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15635              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15636            }}
15637          }},
15638          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15639        }});
15640      }});
15641    }})();
15642
15643    (function() {{
15644      var btn = document.getElementById('suites-expand-btn');
15645      if (!btn) return;
15646      btn.addEventListener('click', function() {{
15647        var D = currentLangTests;
15648        if (!D || !D.length) return;
15649        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15650        if (!top15.length) return;
15651        var h = Math.max(320, top15.length * 36 + 80);
15652        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15653        if (!canvas) return;
15654        new Chart(canvas, {{
15655          type: 'bar',
15656          data: {{
15657            labels: top15.map(function(d){{ return d.lang; }}),
15658            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 }}]
15659          }},
15660          options: {{
15661            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15662            layout: {{ padding: {{ right: 72 }} }},
15663            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15664            scales: {{
15665              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15666              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15667            }}
15668          }},
15669          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15670        }});
15671      }});
15672    }})();
15673
15674    // Wire trend control selectors — re-render without re-fetching
15675    (function() {{
15676      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15677        var el = document.getElementById(id);
15678        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15679      }});
15680    }})();
15681
15682    function loadTrend() {{
15683      var url = '/api/metrics/history?limit=100';
15684      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15685      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15686        buildTrend(data);
15687        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15688        var btn = document.getElementById('multi-compare-trend-btn');
15689        if (btn) {{
15690          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15691          if (ids.length >= 2) {{
15692            btn.style.display = '';
15693            btn.onclick = function() {{
15694              // Reverse so oldest first (API returns newest first).
15695              var sorted = ids.slice().reverse();
15696              if (sorted.length === 2) {{
15697                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15698              }} else {{
15699                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15700              }}
15701            }};
15702          }} else {{
15703            btn.style.display = 'none';
15704          }}
15705        }}
15706      }}).catch(function(){{
15707        var trendEmpty = document.getElementById('trend-empty');
15708        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15709      }});
15710    }}
15711
15712    // Re-render charts on theme toggle
15713    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15714      setTimeout(function() {{
15715        ALL_CHARTS.forEach(function(c) {{
15716          if (c && c.options && c.options.scales) {{
15717            Object.values(c.options.scales).forEach(function(ax) {{
15718              if (ax.grid) ax.grid.color = clr();
15719              if (ax.ticks) ax.ticks.color = txtClr();
15720            }});
15721            c.update();
15722          }}
15723        }});
15724      }}, 80);
15725    }});
15726
15727    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15728    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15729    function tmExportMeta() {{
15730      var sel = document.getElementById('scope-sel');
15731      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15732      if (!proj || proj === '__all__') proj = 'All projects';
15733      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15734      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15735      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15736      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15737      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15738    }}
15739
15740    function exportTmXLSX() {{
15741      var D = currentLangTests;
15742      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15743      var t = tmExportMeta();
15744      function s2b(s) {{ return new TextEncoder().encode(s); }}
15745      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
15746      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; }}
15747      function crc32(d) {{
15748        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;}}}}
15749        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15750      }}
15751      // Store all cells as strings so Excel left-aligns uniformly.
15752      function cs(addr, val, bold) {{
15753        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15754      }}
15755      // Build an Excel Table XML definition for a given sheet range and columns.
15756      function makeTableXml(tblId, name, ref, cols) {{
15757        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15758        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15759        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15760        x+='<autoFilter ref="'+ref+'"/>';
15761        x+='<tableColumns count="'+cols.length+'">';
15762        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15763        x+='</tableColumns>';
15764        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15765        return x+'</table>';
15766      }}
15767      // Worksheet XML with optional Excel Table part reference.
15768      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15769        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15770        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15771        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15772        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15773        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15774        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>';}});
15775        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>';}}
15776        x+='</sheetData>';
15777        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15778        return x+'</worksheet>';
15779      }}
15780
15781      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15782      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15783      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15784      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15785      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15786      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15787
15788      // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15789      // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15790      var sheets=[];
15791
15792      // Sheet: Summary
15793      var sumHdr=['Metric','Value'];
15794      var sumRows=[
15795        ['Project / Scope', t.proj],
15796        ['Export Date', t.full],
15797        ['Test Functions', Number(totTests).toLocaleString()],
15798        ['Assertions', Number(totAssert).toLocaleString()],
15799        ['Test Suites', Number(totSuites).toLocaleString()],
15800        ['Languages with Tests', String(D.length)],
15801        ['Total Code Lines', Number(totCode).toLocaleString()],
15802        ['Average Density (per 1K)', String(avgDensity)],
15803      ];
15804      sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15805
15806      // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15807      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15808      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)];}});
15809      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15810      sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15811
15812      // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15813      var covDs=(typeof getDataset==='function')?getDataset():null;
15814      if(covDs&&covDs.has_coverage){{
15815        var covT=covDs.totals||{{}};
15816        var covSumHdr=['Metric','Value'];
15817        var covSumRows=[
15818          ['Line Coverage', (covT.cov_line||'0')+'%'],
15819          ['Function Coverage', (covT.cov_fn||'0')+'%'],
15820          ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15821        ];
15822        if(covDs.cov_tiers){{
15823          covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15824          covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
15825          covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15826        }}
15827        sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15828
15829        if(covDs.cov&&covDs.cov.length){{
15830          var covLangHdr=['Language','Line Coverage %'];
15831          var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15832          sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15833        }}
15834        if(covFileData&&covFileData.length){{
15835          var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15836          var covFileRows=covFileData.map(function(f){{
15837            var noFn=f.fn_pct<0;
15838            return[f.rel,f.lang,Number(f.line_pct).toFixed(1),String(f.lhit),String(f.lfound),noFn?'—':Number(f.fn_pct).toFixed(1),noFn?'—':String(f.fhit),noFn?'—':String(f.ffound)];
15839          }});
15840          sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15841        }}
15842      }}
15843
15844      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>';
15845      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>';
15846
15847      // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15848      var files=[];
15849      var ctOverrides='', wbSheetTags='', wbRelTags='';
15850      sheets.forEach(function(sh,i){{
15851        var n=i+1;
15852        var lastCol=col2l(sh.hdr.length);
15853        var ref='A1:'+lastCol+(sh.rows.length+1);
15854        var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15855        var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15856        var shRels='<?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/table'+n+'.xml"/></Relationships>';
15857        files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15858        files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15859        files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15860        ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15861        ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15862        wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15863        wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15864      }});
15865      var styleRid='rId'+(sheets.length+1);
15866      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"/>'+ctOverrides+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15867      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="'+styleRid+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'+wbRelTags+'</Relationships>';
15868      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>'+wbSheetTags+'</sheets></workbook>';
15869      files.unshift(
15870        {{name:'[Content_Types].xml',data:s2b(ct)}},
15871        {{name:'_rels/.rels',data:s2b(dotrels)}},
15872        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15873        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15874        {{name:'xl/styles.xml',data:s2b(styl)}}
15875      );
15876      var parts=[],offsets=[],total=0;
15877      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;}});
15878      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;}});
15879      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));
15880      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;}});
15881      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15882      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15883      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15884      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15885    }}
15886
15887    function exportTmPNG() {{
15888      // Map canvas IDs to display titles
15889      var CHART_TITLES = {{
15890        'canvas-trend':       'TEST COUNT TREND',
15891        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15892        'canvas-density':     'TEST DENSITY (per 1,000 code lines)',
15893        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15894        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15895        'canvas-files':       'TEST FILES BREAKDOWN',
15896        'canvas-composition': 'TEST COMPOSITION',
15897        'canvas-cov':         'LINE COVERAGE % BY LANGUAGE',
15898        'canvas-cov-tiers':   'COVERAGE TIER DISTRIBUTION'
15899      }};
15900      // Coverage canvases are only appended when the LCOV panel is visible (has data).
15901      var covPanelEl=document.getElementById('cov-panel');
15902      var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15903      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15904      if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15905      // Include only charts that actually rendered data. A "no data" chart has its
15906      // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15907      // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15908      function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15909      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15910      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15911      var t=tmExportMeta();
15912      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15913      var trendCanvas=document.getElementById('canvas-trend');
15914      var hasTrend=chartHasData(trendCanvas);
15915      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15916      var TOTAL_W=COLW*2+GAP;
15917      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15918      TREND_H=Math.min(Math.max(200,TREND_H),340);
15919      // Per-row chart heights (2-col grid)
15920      var gridRows=Math.ceil(gridCanvases.length/2);
15921      var rowHeights=[];
15922      for(var ri=0;ri<gridRows;ri++){{
15923        var rh=240;
15924        for(var ci=0;ci<2;ci++){{
15925          var cv=gridCanvases[ri*2+ci];
15926          if(cv&&cv.width>0){{
15927            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15928            rh=Math.max(rh,Math.min(420,nat));
15929          }}
15930        }}
15931        rowHeights.push(rh);
15932      }}
15933      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15934      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15935      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15936      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15937      var ctx=out.getContext('2d');
15938      var cs2=getComputedStyle(document.body);
15939      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15940      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15941      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15942
15943      // Background
15944      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15945
15946      // Orange header block
15947      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15948      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15949      ctx.fillText('Test Metrics — '+t.proj,22,42);
15950      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15951      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15952      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15953
15954      // Helper: draw a section title label
15955      function drawTitle(label, x, y, w) {{
15956        ctx.save();
15957        ctx.fillStyle=oxide;
15958        ctx.font='700 11px '+TM_FONT;
15959        ctx.textBaseline='middle';
15960        ctx.textAlign='left';
15961        ctx.letterSpacing='0.07em';
15962        ctx.fillText(label, x+2, y+TITLE_H/2);
15963        // Underline
15964        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15965        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15966        ctx.globalAlpha=1;
15967        ctx.restore();
15968      }}
15969
15970      var yOff=HEADER_H;
15971
15972      // Trend chart (full width)
15973      if(hasTrend){{
15974        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15975        yOff+=TITLE_H;
15976        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15977        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15978        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15979        ctx.drawImage(surf,0,yOff);
15980        yOff+=TREND_H+ROW_PAD;
15981      }}
15982
15983      // Grid charts (2-col), each cell gets title + chart
15984      for(var gi=0;gi<gridRows;gi++){{
15985        var rh2=rowHeights[gi];
15986        // Draw row titles and charts
15987        for(var gci=0;gci<2;gci++){{
15988          var idx2=gi*2+gci;
15989          if(idx2>=gridCanvases.length)continue;
15990          var gcv=gridCanvases[idx2];
15991          var gx=gci*(COLW+GAP);
15992          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15993        }}
15994        yOff+=TITLE_H;
15995        for(var gci2=0;gci2<2;gci2++){{
15996          var idx3=gi*2+gci2;
15997          if(idx3>=gridCanvases.length)continue;
15998          var gcv2=gridCanvases[idx3];
15999          var gx2=gci2*(COLW+GAP);
16000          var natW=gcv2.width,natH=gcv2.height;
16001          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
16002          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
16003          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
16004          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
16005          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
16006          ctx.drawImage(surf2,gx2,yOff);
16007        }}
16008        yOff+=rh2+ROW_PAD;
16009      }}
16010
16011      // Dark footer
16012      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
16013      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
16014      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
16015
16016      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16017      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
16018    }}
16019
16020    function exportTmPDF(ev) {{
16021      var D=currentLangTests;
16022      var t=tmExportMeta();
16023      var strips=document.querySelectorAll('.summary-strip');
16024      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
16025      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
16026      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
16027      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
16028      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
16029      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
16030      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
16031      var rows='';
16032      (D||[]).forEach(function(d){{
16033        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
16034          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
16035          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
16036          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
16037          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
16038          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
16039          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
16040      }});
16041      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
16042        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
16043        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
16044        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
16045        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
16046        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
16047        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
16048      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>';
16049      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
16050        +'html,body{{height:100%;margin:0;}}'
16051        +'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;}}'
16052        +'.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;}}'
16053        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
16054        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
16055        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
16056        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
16057        +'.rep-body{{padding:20px 32px;flex:1;}}'
16058        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
16059        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
16060        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
16061        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
16062        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
16063        +'.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;}}'
16064        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
16065        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
16066        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
16067        +'.n{{text-align:right;}}'
16068        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
16069        +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
16070        +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
16071        +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
16072        +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
16073        +'.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;}}'
16074        +'</style>';
16075      // LCOV Coverage Summary section — only rendered when the current scope has coverage.
16076      var covDs=(typeof getDataset==='function')?getDataset():null;
16077      var covHtml='';
16078      if(covDs&&covDs.has_coverage){{
16079        var covT=covDs.totals||{{}};
16080        covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
16081          +'<div class="cov-strip">'
16082          +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
16083          +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
16084          +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
16085          +'</div>';
16086        if(covFileData&&covFileData.length){{
16087          var cfrows='';
16088          covFileData.forEach(function(f){{
16089            var noFn=f.fn_pct<0;
16090            cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
16091              +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
16092              +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
16093              +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
16094              +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
16095          }});
16096          covHtml+='<div class="section-hdr">Coverage File Detail</div>'
16097            +'<table><thead><tr><th>File</th><th>Lang</th><th class="n">Line %</th><th class="n">Lines Hit / Found</th><th class="n">Fn %</th><th class="n">Fns Hit / Found</th></tr></thead><tbody>'+cfrows+'</tbody></table>';
16098        }}
16099      }}
16100      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
16101        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
16102        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
16103        +'<div class="rep-body">'+statsHtml
16104        +'<div class="section-hdr">Language Breakdown</div>'
16105        +tableHtml+covHtml+'</div>'
16106        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
16107        +'</body></html>';
16108      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16109      var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
16110      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
16111    }}
16112
16113    (function() {{
16114      // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
16115      // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
16116      var xBtn=document.getElementById('tm-export-xlsx-btn');
16117      var pngBtn=document.getElementById('tm-export-png-btn');
16118      var pdfBtn=document.getElementById('tm-export-pdf-btn');
16119      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
16120      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
16121      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
16122    }})();
16123
16124    applyScope();
16125  }})();
16126  </script>
16127  <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>
16128  {toast_assets}
16129</body>
16130</html>"#,
16131    );
16132    (
16133        [(axum::http::header::CACHE_CONTROL, "no-store")],
16134        Html(html),
16135    )
16136        .into_response()
16137}
16138
16139// ── Embeddable widget ─────────────────────────────────────────────────────────
16140// Protected. Returns a self-contained HTML page suitable for iframing inside
16141// Jenkins build summaries, Confluence iframe macros, or Jira panels.
16142//
16143// GET /embed/summary?run_id=<uuid>&theme=dark
16144
16145#[derive(Deserialize)]
16146struct EmbedQuery {
16147    run_id: Option<String>,
16148    theme: Option<String>,
16149}
16150
16151async fn embed_handler(
16152    State(state): State<AppState>,
16153    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
16154    Query(query): Query<EmbedQuery>,
16155) -> Response {
16156    let entry = {
16157        let reg = state.registry.lock().await;
16158        query.run_id.as_ref().map_or_else(
16159            || reg.entries.first().cloned(),
16160            |id| reg.find_by_run_id(id).cloned(),
16161        )
16162    };
16163
16164    let Some(entry) = entry else {
16165        return Html(
16166            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
16167                .to_string(),
16168        )
16169        .into_response();
16170    };
16171
16172    let dark = query.theme.as_deref() == Some("dark");
16173    let languages: Vec<(String, u64, u64)> = entry
16174        .json_path
16175        .as_ref()
16176        .and_then(|p| read_json(p).ok())
16177        .map(|run| {
16178            run.totals_by_language
16179                .iter()
16180                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
16181                .collect()
16182        })
16183        .unwrap_or_default();
16184
16185    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
16186}
16187
16188fn render_embed_widget(
16189    entry: &RegistryEntry,
16190    languages: &[(String, u64, u64)],
16191    dark: bool,
16192    csp_nonce: &str,
16193) -> String {
16194    let s = &entry.summary;
16195    let total = s.code_lines + s.comment_lines + s.blank_lines;
16196    let code_pct = s
16197        .code_lines
16198        .checked_mul(100)
16199        .and_then(|n| n.checked_div(total))
16200        .unwrap_or(0);
16201
16202    let (bg, fg, surface, muted, border) = if dark {
16203        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
16204    } else {
16205        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
16206    };
16207
16208    let mut lang_rows = String::new();
16209    for (name, files, code) in languages {
16210        write!(
16211            lang_rows,
16212            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
16213            escape_html(name),
16214            format_number(*files),
16215            format_number(*code),
16216        )
16217        .ok();
16218    }
16219
16220    let lang_table = if lang_rows.is_empty() {
16221        String::new()
16222    } else {
16223        format!(
16224            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
16225        )
16226    };
16227
16228    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
16229    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
16230    let project_esc = escape_html(&entry.project_label);
16231    let code_lines = format_number(s.code_lines);
16232    let comment_lines = format_number(s.comment_lines);
16233    let files = format_number(s.files_analyzed);
16234    let code_raw = s.code_lines;
16235    let comment_raw = s.comment_lines;
16236    let blank_raw = s.blank_lines;
16237
16238    format!(
16239        r#"<!doctype html>
16240<html lang="en">
16241<head>
16242  <meta charset="utf-8">
16243  <meta name="viewport" content="width=device-width,initial-scale=1">
16244  <title>OxideSLOC &mdash; {project_esc}</title>
16245  <script src="/static/chart.js"></script>
16246  <style nonce="{csp_nonce}">
16247    *{{box-sizing:border-box;margin:0;padding:0}}
16248    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16249    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16250    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16251    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16252    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16253    .card .v{{font-size:18px;font-weight:700}}
16254    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16255    .row{{display:flex;gap:12px;align-items:flex-start}}
16256    .pie{{width:120px;height:120px;flex-shrink:0}}
16257    .lt{{border-collapse:collapse;width:100%;flex:1}}
16258    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16259    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16260    .n{{text-align:right}}
16261    .footer{{margin-top:10px;color:{muted};font-size:10px}}
16262  </style>
16263</head>
16264<body>
16265  <h2>{project_esc}</h2>
16266  <div class="sub">{timestamp} &middot; run {run_short}</div>
16267  <div class="cards">
16268    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16269    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16270    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16271    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16272  </div>
16273  <div class="row">
16274    <canvas class="pie" id="c"></canvas>
16275    {lang_table}
16276  </div>
16277  <div class="footer">oxide-sloc</div>
16278  <script nonce="{csp_nonce}">
16279    new Chart(document.getElementById('c'),{{
16280      type:'doughnut',
16281      data:{{
16282        labels:['Code','Comments','Blank'],
16283        datasets:[{{
16284          data:[{code_raw},{comment_raw},{blank_raw}],
16285          backgroundColor:['#4a78ee','#b35428','#aaa'],
16286          borderWidth:0
16287        }}]
16288      }},
16289      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16290    }});
16291  </script>
16292</body>
16293</html>"#
16294    )
16295}
16296
16297/// Returns a process-wide mutex unique to `dir`, so that two requests writing
16298/// artifacts into the *same* output directory (e.g. re-ingesting an identical
16299/// `run_id`) serialize instead of corrupting each other's files. Directories that
16300/// differ never contend, so legitimate parallel analyses keep their throughput.
16301fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16302    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16303        OnceLock::new();
16304    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16305    let mut guard = map
16306        .lock()
16307        .unwrap_or_else(std::sync::PoisonError::into_inner);
16308    guard
16309        .entry(dir.to_path_buf())
16310        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16311        .clone()
16312}
16313
16314#[allow(clippy::too_many_lines)]
16315fn persist_run_artifacts(
16316    run: &sloc_core::AnalysisRun,
16317    report_html: &str,
16318    run_dir: &Path,
16319    report_title: &str,
16320    file_stem: &str,
16321    result_context: RunResultContext,
16322) -> Result<(RunArtifacts, PendingPdf)> {
16323    // Serialize concurrent writers targeting this same output directory so their
16324    // file writes cannot interleave and corrupt one another.
16325    let dir_lock = output_dir_lock(run_dir);
16326    let _dir_guard = dir_lock
16327        .lock()
16328        .unwrap_or_else(std::sync::PoisonError::into_inner);
16329
16330    // Root dir + organised subdirectories.
16331    let html_dir = run_dir.join("html");
16332    let pdf_dir = run_dir.join("pdf");
16333    let excel_dir = run_dir.join("excel");
16334    let json_dir = run_dir.join("json");
16335    let submodules_dir = run_dir.join("submodules");
16336    for dir in &[
16337        run_dir,
16338        &html_dir,
16339        &pdf_dir,
16340        &excel_dir,
16341        &json_dir,
16342        &submodules_dir,
16343    ] {
16344        fs::create_dir_all(dir)
16345            .with_context(|| format!("failed to create directory {}", dir.display()))?;
16346    }
16347
16348    // HTML report in html/.
16349    let html_path = {
16350        let path = html_dir.join(format!("report_{file_stem}.html"));
16351        fs::write(&path, report_html)
16352            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16353        Some(path)
16354    };
16355
16356    // JSON result in json/.
16357    let json_path = {
16358        let path = json_dir.join(format!("result_{file_stem}.json"));
16359        let json = serde_json::to_string_pretty(run)
16360            .context("failed to serialize analysis run to JSON")?;
16361        fs::write(&path, json)
16362            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16363        Some(path)
16364    };
16365
16366    // PDF in pdf/.
16367    let (pdf_path, pending_pdf) = {
16368        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16369        match write_pdf_from_run(run, &pdf_dest) {
16370            Ok(()) => {
16371                eprintln!(
16372                    "[oxide-sloc][pdf] native PDF written to {}",
16373                    pdf_dest.display()
16374                );
16375                (Some(pdf_dest), None)
16376            }
16377            Err(native_err) => {
16378                eprintln!(
16379                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16380                );
16381                let source_html_path = html_path
16382                    .as_ref()
16383                    .expect("html_path always Some here")
16384                    .clone();
16385                let pending = Some((source_html_path, pdf_dest.clone(), false));
16386                (Some(pdf_dest), pending)
16387            }
16388        }
16389    };
16390
16391    // CSV and XLSX in excel/.
16392    let csv_path = {
16393        let path = excel_dir.join(format!("report_{file_stem}.csv"));
16394        match sloc_report::write_csv(run, &path) {
16395            Err(e) => {
16396                eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16397                None
16398            }
16399            _ => Some(path),
16400        }
16401    };
16402
16403    let xlsx_path = {
16404        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16405        match sloc_report::write_xlsx(run, &path) {
16406            Err(e) => {
16407                eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16408                None
16409            }
16410            _ => Some(path),
16411        }
16412    };
16413
16414    // Scan config in json/.
16415    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16416
16417    // Eagerly generate sub-reports before index.html so relative links work.
16418    if run.effective_configuration.discovery.submodule_breakdown {
16419        let run_id = &run.tool.run_id;
16420        for s in &run.submodule_summaries {
16421            build_submodule_row(s, run, run_id, run_dir);
16422        }
16423    }
16424
16425    // index.html at root — offline static export of the result-page dashboard.
16426    generate_offline_index(
16427        run,
16428        run_dir,
16429        file_stem,
16430        html_path.as_deref(),
16431        pdf_path.as_deref(),
16432        json_path.as_deref(),
16433        scan_config_path.as_deref(),
16434        &result_context,
16435    );
16436
16437    Ok((
16438        RunArtifacts {
16439            output_dir: run_dir.to_path_buf(),
16440            html_path,
16441            pdf_path,
16442            json_path,
16443            csv_path,
16444            xlsx_path,
16445            scan_config_path,
16446            report_title: report_title.to_string(),
16447            result_context,
16448        },
16449        pending_pdf,
16450    ))
16451}
16452
16453/// Render a static offline result-page dashboard and write it as `index.html` at
16454/// the root of the run output directory so business users can open it from disk.
16455#[allow(clippy::too_many_arguments)]
16456#[allow(clippy::too_many_lines)]
16457#[allow(clippy::similar_names)]
16458fn generate_offline_index(
16459    run: &sloc_core::AnalysisRun,
16460    run_dir: &Path,
16461    file_stem: &str,
16462    html_path: Option<&Path>,
16463    pdf_path: Option<&Path>,
16464    json_path: Option<&Path>,
16465    scan_config_path: Option<&Path>,
16466    result_context: &RunResultContext,
16467) {
16468    let prev_entry = &result_context.prev_entry;
16469    let prev_scan_count = result_context.prev_scan_count;
16470    let project_path = &result_context.project_path;
16471
16472    let scan_delta = prev_entry.as_ref().and_then(|prev| {
16473        prev.json_path
16474            .as_ref()
16475            .and_then(|p| read_json(p).ok())
16476            .map(|prev_run| compute_delta(&prev_run, run))
16477    });
16478
16479    let files_analyzed = run.per_file_records.len() as u64;
16480    let files_skipped = run.skipped_file_records.len() as u64;
16481    let totals = sum_lang_totals(run);
16482
16483    let DeltaFields {
16484        prev_fa_str,
16485        prev_fs_str,
16486        prev_pl_str,
16487        prev_cl_str,
16488        prev_cml_str,
16489        prev_bl_str,
16490        delta_fa_str,
16491        delta_fa_class,
16492        delta_fs_str,
16493        delta_fs_class,
16494        delta_pl_str,
16495        delta_pl_class,
16496        delta_cl_str,
16497        delta_cl_class,
16498        delta_cml_str,
16499        delta_cml_class,
16500        delta_bl_str,
16501        delta_bl_class,
16502        delta_lines_added,
16503        delta_lines_removed,
16504        delta_lines_net_str,
16505        delta_lines_net_class,
16506    } = compute_delta_fields(
16507        prev_entry.as_ref(),
16508        &totals,
16509        files_analyzed,
16510        files_skipped,
16511        scan_delta.as_ref(),
16512    );
16513
16514    let git_commit_url = git_commit_url_for(run);
16515    let git_branch_url = git_branch_url_for(run);
16516    let scan_performed_by = scan_performed_by(run);
16517
16518    // Convert absolute path to relative from run_dir (for file:// navigation).
16519    let make_rel = |p: Option<&Path>| -> Option<String> {
16520        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16521            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16522    };
16523
16524    let run_id = &run.tool.run_id;
16525
16526    // Submodule rows with relative paths into submodules/.
16527    let submodule_rows: Vec<SubmoduleRow> = run
16528        .submodule_summaries
16529        .iter()
16530        .map(|s| {
16531            let safe = sanitize_project_label(&s.name);
16532            let key = format!("sub_{safe}");
16533            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16534            SubmoduleRow {
16535                name: s.name.clone(),
16536                relative_path: s.relative_path.clone(),
16537                files_analyzed: s.files_analyzed,
16538                code_lines: s.code_lines,
16539                comment_lines: s.comment_lines,
16540                blank_lines: s.blank_lines,
16541                total_physical_lines: s.total_physical_lines,
16542                html_url: if sub_path.exists() {
16543                    Some(format!("submodules/{key}.html"))
16544                } else {
16545                    None
16546                },
16547            }
16548        })
16549        .collect();
16550
16551    let lang_chart_json = build_lang_chart_json(run);
16552
16553    let scan_config_rel =
16554        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16555
16556    let template = ResultTemplate {
16557        version: env!("CARGO_PKG_VERSION"),
16558        report_title: run.effective_configuration.reporting.report_title.clone(),
16559        project_path: project_path.clone(),
16560        output_dir: display_path(run_dir),
16561        run_id: run_id.clone(),
16562        run_id_short: run_id
16563            .split('-')
16564            .next_back()
16565            .unwrap_or(run_id)
16566            .chars()
16567            .take(7)
16568            .collect(),
16569        files_analyzed,
16570        files_skipped,
16571        physical_lines: totals.physical_lines,
16572        code_lines: totals.code_lines,
16573        comment_lines: totals.comment_lines,
16574        blank_lines: totals.blank_lines,
16575        mixed_lines: totals.mixed_lines,
16576        functions: totals.functions,
16577        classes: totals.classes,
16578        variables: totals.variables,
16579        imports: totals.imports,
16580        html_url: make_rel(html_path),
16581        pdf_url: make_rel(pdf_path),
16582        json_url: make_rel(json_path),
16583        html_download_url: make_rel(html_path),
16584        pdf_download_url: make_rel(pdf_path),
16585        json_download_url: make_rel(json_path),
16586        html_path: html_path.map(display_path),
16587        json_path: json_path.map(display_path),
16588        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16589        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16590        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16591        prev_fa_str,
16592        prev_fs_str,
16593        prev_pl_str,
16594        prev_cl_str,
16595        prev_cml_str,
16596        prev_bl_str,
16597        delta_fa_str,
16598        delta_fa_class,
16599        delta_fs_str,
16600        delta_fs_class,
16601        delta_pl_str,
16602        delta_pl_class,
16603        delta_cl_str,
16604        delta_cl_class,
16605        delta_cml_str,
16606        delta_cml_class,
16607        delta_bl_str,
16608        delta_bl_class,
16609        delta_lines_added,
16610        delta_lines_removed,
16611        delta_lines_net_str,
16612        delta_lines_net_class,
16613        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16614        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16615        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16616        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16617        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16618        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16619        git_branch: run.git_branch.clone(),
16620        git_branch_url,
16621        git_commit: run.git_commit_short.clone(),
16622        git_commit_long: run.git_commit_long.clone(),
16623        git_author: run.git_commit_author.clone(),
16624        git_commit_url,
16625        scan_performed_by,
16626        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16627        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16628        os_display: format!(
16629            "{} / {}",
16630            run.environment.operating_system, run.environment.architecture
16631        ),
16632        test_count: run.summary_totals.test_count,
16633        test_assertion_count: run.summary_totals.test_assertion_count,
16634        current_scan_number: prev_scan_count + 1,
16635        prev_scan_count,
16636        submodule_rows,
16637        pdf_generating: false,
16638        scan_config_url: scan_config_rel,
16639        lang_chart_json,
16640        scatter_chart_json: build_scatter_chart_json(run),
16641        semantic_chart_json: build_semantic_chart_json(run),
16642        submodule_chart_json: build_submodule_chart_json(run),
16643        has_submodule_data: !run.submodule_summaries.is_empty(),
16644        has_semantic_data: run
16645            .totals_by_language
16646            .iter()
16647            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16648        csp_nonce: String::new(),
16649        confluence_configured: false,
16650        server_mode: false,
16651        report_header_footer: run
16652            .effective_configuration
16653            .reporting
16654            .report_header_footer
16655            .clone(),
16656        is_offline: true,
16657        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16658        lsloc: run.summary_totals.lsloc,
16659        uloc: run.uloc,
16660        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16661        duplicate_group_count: run.duplicate_groups.len(),
16662        has_cocomo: run.cocomo.is_some(),
16663        cocomo_effort_str: run
16664            .cocomo
16665            .as_ref()
16666            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16667        cocomo_duration_str: run
16668            .cocomo
16669            .as_ref()
16670            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16671        cocomo_staff_str: run
16672            .cocomo
16673            .as_ref()
16674            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16675        cocomo_ksloc_str: run
16676            .cocomo
16677            .as_ref()
16678            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16679        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16680            || "Organic".to_string(),
16681            |c| cocomo_mode_label(c.mode).to_string(),
16682        ),
16683        cocomo_mode_tooltip: run
16684            .cocomo
16685            .as_ref()
16686            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16687        complexity_alert: 0,
16688        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16689        cov_line_pct: cov_pct_str(
16690            run.summary_totals.coverage_lines_hit,
16691            run.summary_totals.coverage_lines_found,
16692        ),
16693        cov_fn_pct: cov_pct_str(
16694            run.summary_totals.coverage_functions_hit,
16695            run.summary_totals.coverage_functions_found,
16696        ),
16697        cov_branch_pct: cov_pct_str(
16698            run.summary_totals.coverage_branches_hit,
16699            run.summary_totals.coverage_branches_found,
16700        ),
16701        cov_lines_summary: cov_lines_summary_str(
16702            run.summary_totals.coverage_lines_hit,
16703            run.summary_totals.coverage_lines_found,
16704        ),
16705    };
16706
16707    if let Ok(html) = template.render() {
16708        // Inline the brand + watermark logos as data URIs: a file:// page has no
16709        // server to resolve the /images/logo/* routes, so without this the top-left
16710        // logo and the repeated "Oxide" background watermark render as broken images.
16711        let html = inline_offline_logos(&html);
16712        let index_path = run_dir.join("index.html");
16713        if let Err(e) = fs::write(&index_path, html) {
16714            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16715        }
16716    }
16717}
16718
16719/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
16720/// offline `index.html` displays the brand logo and background watermark when
16721/// opened directly from disk (file://), where the `/images/...` routes do not exist.
16722fn inline_offline_logos(html: &str) -> String {
16723    use base64::Engine;
16724    let text_uri = format!(
16725        "data:image/png;base64,{}",
16726        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16727    );
16728    let small_uri = format!(
16729        "data:image/png;base64,{}",
16730        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16731    );
16732    html.replace("/images/logo/logo-text.png", &text_uri)
16733        .replace("/images/logo/small-logo.png", &small_uri)
16734}
16735
16736/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
16737/// then root (old flat layout), for backwards compatibility.
16738fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16739    // New layout: json/scan-config_*.json
16740    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16741        return Some(found);
16742    }
16743    // Old flat layout: scan-config.json or scan-config_*.json at root
16744    find_scan_config_in_dir_flat(dir)
16745}
16746
16747fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16748    let exact = dir.join("scan-config.json");
16749    if exact.exists() {
16750        return Some(exact);
16751    }
16752    fs::read_dir(dir).ok().and_then(|entries| {
16753        entries
16754            .filter_map(std::result::Result::ok)
16755            .find(|e| {
16756                let name = e.file_name();
16757                let name = name.to_string_lossy();
16758                name.starts_with("scan-config") && name.ends_with(".json")
16759            })
16760            .map(|e| e.path())
16761    })
16762}
16763
16764// ── Config export / import ────────────────────────────────────────────────────
16765
16766/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
16767/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
16768#[derive(Deserialize)]
16769struct ExportPdfRequest {
16770    html: String,
16771    #[serde(default)]
16772    filename: Option<String>,
16773}
16774
16775async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16776    let html_content = body.html;
16777    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16778    if html_content.is_empty() {
16779        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16780    }
16781    // Write HTML to a temp file, run headless Chrome PDF export, read result.
16782    let tmp_dir = std::env::temp_dir();
16783    let html_path = tmp_dir.join(format!(
16784        "sloc-export-{}.html",
16785        uuid::Uuid::new_v4().simple()
16786    ));
16787    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16788    if let Err(e) = std::fs::write(&html_path, &html_content) {
16789        return (
16790            StatusCode::INTERNAL_SERVER_ERROR,
16791            format!("Failed to write temp HTML: {e}"),
16792        )
16793            .into_response();
16794    }
16795    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16796    let _ = std::fs::remove_file(&html_path);
16797    if let Err(e) = pdf_result {
16798        let _ = std::fs::remove_file(&pdf_path);
16799        return (
16800            StatusCode::INTERNAL_SERVER_ERROR,
16801            format!("PDF generation failed: {e}"),
16802        )
16803            .into_response();
16804    }
16805    let pdf_bytes = match std::fs::read(&pdf_path) {
16806        Ok(b) => b,
16807        Err(e) => {
16808            let _ = std::fs::remove_file(&pdf_path);
16809            return (
16810                StatusCode::INTERNAL_SERVER_ERROR,
16811                format!("Failed to read PDF: {e}"),
16812            )
16813                .into_response();
16814        }
16815    };
16816    let _ = std::fs::remove_file(&pdf_path);
16817    let safe_name: String = filename
16818        .chars()
16819        .map(|c| {
16820            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16821                c
16822            } else {
16823                '_'
16824            }
16825        })
16826        .collect();
16827    let disposition = format!("attachment; filename=\"{safe_name}\"");
16828    (
16829        [
16830            (header::CONTENT_TYPE, "application/pdf".to_string()),
16831            (header::CONTENT_DISPOSITION, disposition),
16832        ],
16833        pdf_bytes,
16834    )
16835        .into_response()
16836}
16837
16838async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16839    let toml_str = match toml::to_string_pretty(&state.base_config) {
16840        Ok(s) => s,
16841        Err(e) => {
16842            return (
16843                StatusCode::INTERNAL_SERVER_ERROR,
16844                format!("serialization error: {e}"),
16845            )
16846                .into_response();
16847        }
16848    };
16849    (
16850        [
16851            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16852            (
16853                header::CONTENT_DISPOSITION,
16854                "attachment; filename=\".oxide-sloc.toml\"",
16855            ),
16856        ],
16857        toml_str,
16858    )
16859        .into_response()
16860}
16861
16862#[derive(Serialize)]
16863struct OkResponse {
16864    ok: bool,
16865}
16866
16867#[derive(Serialize)]
16868struct SaveProfileResponse {
16869    ok: bool,
16870    id: String,
16871}
16872
16873#[derive(Serialize)]
16874struct ProfileListResponse {
16875    profiles: Vec<ScanProfile>,
16876}
16877
16878#[derive(Serialize)]
16879struct ImportConfigResponse {
16880    ok: bool,
16881    config: sloc_config::AppConfig,
16882}
16883
16884#[derive(Deserialize)]
16885struct ImportConfigBody {
16886    toml: String,
16887}
16888
16889async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16890    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16891        Ok(config) => {
16892            if let Err(e) = config.validate() {
16893                return error::unprocessable_entity(&e.to_string());
16894            }
16895            Json(ImportConfigResponse { ok: true, config }).into_response()
16896        }
16897        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16898    }
16899}
16900
16901// ── Scan profiles API ─────────────────────────────────────────────────────────
16902
16903async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16904    let store = state.scan_profiles.lock().await;
16905    Json(ProfileListResponse {
16906        profiles: store.profiles.clone(),
16907    })
16908}
16909
16910#[derive(Deserialize)]
16911struct SaveScanProfileBody {
16912    name: String,
16913    params: serde_json::Value,
16914}
16915
16916async fn api_save_scan_profile(
16917    State(state): State<AppState>,
16918    Json(body): Json<SaveScanProfileBody>,
16919) -> impl IntoResponse {
16920    if body.name.trim().is_empty() {
16921        return error::bad_request("name must not be empty");
16922    }
16923
16924    let id = uuid::Uuid::new_v4().to_string();
16925    let profile = ScanProfile {
16926        id: id.clone(),
16927        name: body.name.trim().to_string(),
16928        created_at: chrono::Utc::now().to_rfc3339(),
16929        params: body.params,
16930    };
16931
16932    let mut store = state.scan_profiles.lock().await;
16933    store.profiles.push(profile);
16934    if let Err(e) = store.save(&state.scan_profiles_path) {
16935        tracing::warn!("failed to persist scan profiles: {e}");
16936    }
16937    drop(store);
16938
16939    (
16940        StatusCode::CREATED,
16941        Json(SaveProfileResponse { ok: true, id }),
16942    )
16943        .into_response()
16944}
16945
16946async fn api_delete_scan_profile(
16947    State(state): State<AppState>,
16948    AxumPath(id): AxumPath<String>,
16949) -> impl IntoResponse {
16950    let mut store = state.scan_profiles.lock().await;
16951    let before = store.profiles.len();
16952    store.profiles.retain(|p| p.id != id);
16953    if store.profiles.len() == before {
16954        drop(store);
16955        return error::not_found("profile not found");
16956    }
16957    if let Err(e) = store.save(&state.scan_profiles_path) {
16958        tracing::warn!("failed to persist scan profiles: {e}");
16959    }
16960    drop(store);
16961    Json(OkResponse { ok: true }).into_response()
16962}
16963
16964fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16965    let value = raw.unwrap_or("out/web").trim();
16966    let path = if value.is_empty() {
16967        PathBuf::from("out/web")
16968    } else {
16969        PathBuf::from(value)
16970    };
16971
16972    if path.is_absolute() {
16973        path
16974    } else {
16975        workspace_root().join(path)
16976    }
16977}
16978
16979/// Derive the directory that holds remote-repo clones from the output root.
16980fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16981    std::env::var("SLOC_GIT_CLONES_DIR")
16982        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16983}
16984
16985/// Build a deterministic filesystem path for a cloned remote repository.
16986/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16987pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16988    let safe: String = repo_url
16989        .chars()
16990        .map(|c| {
16991            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16992                c
16993            } else {
16994                '_'
16995            }
16996        })
16997        .take(80)
16998        .collect();
16999    clones_dir.join(safe)
17000}
17001
17002/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
17003/// Runs synchronously — call from `tokio::task::spawn_blocking`.
17004pub(crate) fn scan_path_to_artifacts(
17005    scan_path: &Path,
17006    base_config: &AppConfig,
17007    label: &str,
17008) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
17009    let mut config = base_config.clone();
17010    config.discovery.root_paths = vec![scan_path.to_path_buf()];
17011    label.clone_into(&mut config.reporting.report_title);
17012    let run = analyze(&config, "git", None, None)?;
17013    let html = render_html(&run)?;
17014    let run_id = run.tool.run_id.clone();
17015    let project_label = sanitize_project_label(label);
17016    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
17017    let file_stem = {
17018        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
17019        if commit.is_empty() {
17020            project_label
17021        } else {
17022            format!("{project_label}_{commit}")
17023        }
17024    };
17025    let (artifacts, _pending_pdf) = persist_run_artifacts(
17026        &run,
17027        &html,
17028        &output_dir,
17029        label,
17030        &file_stem,
17031        RunResultContext::default(),
17032    )?;
17033    Ok((run_id, artifacts, run))
17034}
17035
17036/// Re-spawn background poll tasks for any polling schedules saved to disk.
17037async fn restart_poll_schedules(state: &AppState) {
17038    let store = state.schedules.lock().await;
17039    let poll_schedules: Vec<_> = store
17040        .schedules
17041        .iter()
17042        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
17043        .cloned()
17044        .collect();
17045    drop(store);
17046    for schedule in poll_schedules {
17047        let interval = schedule.interval_secs.unwrap_or(300);
17048        let st = state.clone();
17049        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
17050    }
17051}
17052
17053/// Warn at startup when GitLab webhook schedules exist but native TLS is not
17054/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
17055/// header (no HMAC over the body), so the token is exposed in cleartext unless
17056/// the transport is encrypted. This is only an advisory — TLS may be terminated
17057/// by an upstream reverse proxy, in which case the warning can be ignored.
17058async fn warn_insecure_gitlab_webhooks(state: &AppState) {
17059    if state.tls_enabled {
17060        return;
17061    }
17062    let store = state.schedules.lock().await;
17063    let has_gitlab_webhook = store.schedules.iter().any(|s| {
17064        s.kind == sloc_git::ScanScheduleKind::Webhook
17065            && s.provider == sloc_git::ScanScheduleProvider::GitLab
17066    });
17067    drop(store);
17068    if has_gitlab_webhook {
17069        tracing::warn!(
17070            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
17071             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
17072             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
17073             proxy so the token is not exposed in cleartext."
17074        );
17075    }
17076}
17077
17078fn split_patterns(raw: Option<&str>) -> Vec<String> {
17079    raw.unwrap_or("")
17080        .lines()
17081        .flat_map(|line| line.split(','))
17082        .map(str::trim)
17083        .filter(|part| !part.is_empty())
17084        .map(ToOwned::to_owned)
17085        .collect()
17086}
17087
17088#[must_use]
17089pub fn build_sub_run(
17090    parent: &AnalysisRun,
17091    sub: &sloc_core::SubmoduleSummary,
17092    parent_path: &str,
17093) -> AnalysisRun {
17094    let sub_files: Vec<_> = parent
17095        .per_file_records
17096        .iter()
17097        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
17098        .cloned()
17099        .collect();
17100    let mut config = parent.effective_configuration.clone();
17101    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
17102
17103    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
17104    let mut functions = 0u64;
17105    let mut classes = 0u64;
17106    let mut variables = 0u64;
17107    let mut imports = 0u64;
17108    let mut test_count = 0u64;
17109    let mut test_assertion_count = 0u64;
17110    let mut test_suite_count = 0u64;
17111    let mut mixed_lines_separate = 0u64;
17112    let mut coverage_lines_found = 0u64;
17113    let mut coverage_lines_hit = 0u64;
17114    let mut coverage_functions_found = 0u64;
17115    let mut coverage_functions_hit = 0u64;
17116    let mut coverage_branches_found = 0u64;
17117    let mut coverage_branches_hit = 0u64;
17118    for r in &sub_files {
17119        functions += r.raw_line_categories.functions;
17120        classes += r.raw_line_categories.classes;
17121        variables += r.raw_line_categories.variables;
17122        imports += r.raw_line_categories.imports;
17123        test_count += r.raw_line_categories.test_count;
17124        test_assertion_count += r.raw_line_categories.test_assertion_count;
17125        test_suite_count += r.raw_line_categories.test_suite_count;
17126        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
17127        if let Some(cov) = &r.coverage {
17128            coverage_lines_found += u64::from(cov.lines_found);
17129            coverage_lines_hit += u64::from(cov.lines_hit);
17130            coverage_functions_found += u64::from(cov.functions_found);
17131            coverage_functions_hit += u64::from(cov.functions_hit);
17132            coverage_branches_found += u64::from(cov.branches_found);
17133            coverage_branches_hit += u64::from(cov.branches_hit);
17134        }
17135    }
17136
17137    AnalysisRun {
17138        tool: parent.tool.clone(),
17139        environment: parent.environment.clone(),
17140        effective_configuration: config,
17141        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
17142        summary_totals: SummaryTotals {
17143            files_considered: sub.files_analyzed,
17144            files_analyzed: sub.files_analyzed,
17145            files_skipped: 0,
17146            total_physical_lines: sub.total_physical_lines,
17147            code_lines: sub.code_lines,
17148            comment_lines: sub.comment_lines,
17149            blank_lines: sub.blank_lines,
17150            mixed_lines_separate,
17151            functions,
17152            classes,
17153            variables,
17154            imports,
17155            test_count,
17156            test_assertion_count,
17157            test_suite_count,
17158            coverage_lines_found,
17159            coverage_lines_hit,
17160            coverage_functions_found,
17161            coverage_functions_hit,
17162            coverage_branches_found,
17163            coverage_branches_hit,
17164            cyclomatic_complexity: 0,
17165            lsloc: None,
17166            ..Default::default()
17167        },
17168        totals_by_language: sub.language_summaries.clone(),
17169        per_file_records: sub_files,
17170        skipped_file_records: vec![],
17171        warnings: vec![],
17172        submodule_summaries: vec![],
17173        git_commit_short: sub.git_commit_short.clone(),
17174        git_commit_long: sub.git_commit_long.clone(),
17175        git_branch: sub.git_branch.clone(),
17176        git_commit_author: sub.git_commit_author.clone(),
17177        git_commit_date: sub.git_commit_date.clone(),
17178        git_tags: None,
17179        git_nearest_tag: None,
17180        git_remote_url: sub.git_remote_url.clone(),
17181        style_summary: None,
17182        cocomo: None,
17183        uloc: 0,
17184        dryness_pct: None,
17185        duplicate_groups: vec![],
17186        duplicates_excluded: 0,
17187    }
17188}
17189
17190#[must_use]
17191pub fn sanitize_project_label(raw: &str) -> String {
17192    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
17193    // where `Path` treats '\' as a literal character, not a separator.
17194    let candidate = raw
17195        .split(['/', '\\'])
17196        .rfind(|s| !s.is_empty())
17197        .unwrap_or("project");
17198
17199    let mut value = String::with_capacity(candidate.len());
17200    for ch in candidate.chars() {
17201        if ch.is_ascii_alphanumeric() {
17202            value.push(ch.to_ascii_lowercase());
17203        } else {
17204            value.push('-');
17205        }
17206    }
17207
17208    let compact = value.trim_matches('-').to_string();
17209    if compact.is_empty() {
17210        "project".to_string()
17211    } else {
17212        compact
17213    }
17214}
17215
17216/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
17217/// comparisons with non-canonicalized stored paths work correctly.
17218fn strip_unc_prefix(path: PathBuf) -> PathBuf {
17219    let s = path.to_string_lossy();
17220    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17221        return PathBuf::from(format!(r"\\{rest}"));
17222    }
17223    if let Some(rest) = s.strip_prefix(r"\\?\") {
17224        return PathBuf::from(rest);
17225    }
17226    path
17227}
17228
17229/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
17230/// commit page URL for the most common hosting platforms.
17231fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
17232    let base = if let Some(rest) = remote.strip_prefix("git@") {
17233        let (host, path) = rest.split_once(':')?;
17234        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17235    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17236        remote
17237            .trim_end_matches('/')
17238            .trim_end_matches(".git")
17239            .to_owned()
17240    } else {
17241        return None;
17242    };
17243    let base = base.trim_end_matches('/');
17244    // GitLab uses /-/commit/; everything else uses /commit/
17245    if base.contains("gitlab.com") || base.contains("gitlab.") {
17246        Some(format!("{base}/-/commit/{sha}"))
17247    } else if base.contains("bitbucket.org") {
17248        Some(format!("{base}/commits/{sha}"))
17249    } else {
17250        Some(format!("{base}/commit/{sha}"))
17251    }
17252}
17253
17254/// Convert a git remote URL (https or git@) + branch name into a browser-openable
17255/// branch page URL for the most common hosting platforms.
17256fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17257    let base = if let Some(rest) = remote.strip_prefix("git@") {
17258        let (host, path) = rest.split_once(':')?;
17259        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17260    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17261        remote
17262            .trim_end_matches('/')
17263            .trim_end_matches(".git")
17264            .to_owned()
17265    } else {
17266        return None;
17267    };
17268    let base = base.trim_end_matches('/');
17269    if base.contains("gitlab.com") || base.contains("gitlab.") {
17270        Some(format!("{base}/-/tree/{branch}"))
17271    } else {
17272        Some(format!("{base}/tree/{branch}"))
17273    }
17274}
17275
17276fn display_path(path: &Path) -> String {
17277    let s = path.to_string_lossy();
17278    // Strip Windows extended-length prefix for display only; the underlying
17279    // PathBuf remains unchanged so file operations are unaffected.
17280    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
17281    // \\?\C:\path           →  C:\path          (local drive)
17282    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17283        return format!(r"\\{rest}");
17284    }
17285    if let Some(rest) = s.strip_prefix(r"\\?\") {
17286        return rest.to_owned();
17287    }
17288    s.into_owned()
17289}
17290
17291fn sanitize_path_str(s: &str) -> String {
17292    // Forward-slash variants of the Windows extended-length prefix that appear
17293    // when paths stored as plain strings have been processed through some path
17294    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
17295    if let Some(rest) = s.strip_prefix("//?/UNC/") {
17296        return format!("//{rest}");
17297    }
17298    if let Some(rest) = s.strip_prefix("//?/") {
17299        return rest.to_owned();
17300    }
17301    display_path(Path::new(s))
17302}
17303
17304fn workspace_root() -> PathBuf {
17305    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
17306    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17307        let p = PathBuf::from(root);
17308        if p.is_dir() {
17309            return p;
17310        }
17311    }
17312
17313    // Current working directory — works for `cargo run` from the project root
17314    // and for scripts/run.sh which cds there first.
17315    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17316}
17317
17318/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
17319fn make_git_label(repo: &str, ref_name: &str) -> String {
17320    if repo.is_empty() || ref_name.is_empty() {
17321        return String::new();
17322    }
17323    let base = repo
17324        .trim_end_matches('/')
17325        .trim_end_matches(".git")
17326        .rsplit('/')
17327        .next()
17328        .unwrap_or("repo");
17329    let ref_safe: String = ref_name
17330        .chars()
17331        .map(|c| {
17332            if c.is_alphanumeric() || c == '-' || c == '.' {
17333                c
17334            } else {
17335                '_'
17336            }
17337        })
17338        .collect();
17339    format!("{base}_at_{ref_safe}_sloc")
17340}
17341
17342/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
17343fn desktop_dir() -> PathBuf {
17344    if let Ok(profile) = std::env::var("USERPROFILE") {
17345        let p = PathBuf::from(profile).join("Desktop");
17346        if p.exists() {
17347            return p;
17348        }
17349    }
17350    if let Ok(home) = std::env::var("HOME") {
17351        let p = PathBuf::from(home).join("Desktop");
17352        if p.exists() {
17353            return p;
17354        }
17355    }
17356    workspace_root().join("out").join("web")
17357}
17358
17359fn resolve_input_path(raw: &str) -> PathBuf {
17360    let trimmed = raw.trim();
17361    if trimmed.is_empty() {
17362        return workspace_root().join("samples").join("basic");
17363    }
17364
17365    let candidate = PathBuf::from(trimmed);
17366    let resolved = if candidate.is_absolute() {
17367        candidate
17368    } else {
17369        let rooted = workspace_root().join(&candidate);
17370        if rooted.exists() {
17371            rooted
17372        } else {
17373            workspace_root().join(candidate)
17374        }
17375    };
17376
17377    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
17378    // strip that prefix so stored paths and the displayed "Project path" are clean.
17379    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17380    PathBuf::from(display_path(&canonical))
17381}
17382
17383fn dir_size_bytes(path: &Path) -> u64 {
17384    let mut total = 0u64;
17385    if let Ok(rd) = fs::read_dir(path) {
17386        for entry in rd.filter_map(Result::ok) {
17387            let p = entry.path();
17388            if p.is_file() {
17389                if let Ok(meta) = p.metadata() {
17390                    total += meta.len();
17391                }
17392            } else if p.is_dir() {
17393                total += dir_size_bytes(&p);
17394            }
17395        }
17396    }
17397    total
17398}
17399
17400#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
17401fn format_dir_size(bytes: u64) -> String {
17402    if bytes >= 1_073_741_824 {
17403        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17404    } else if bytes >= 1_048_576 {
17405        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17406    } else if bytes >= 1_024 {
17407        format!("{:.0} KB", bytes as f64 / 1_024.0)
17408    } else {
17409        format!("{bytes} B")
17410    }
17411}
17412
17413fn render_submodule_chips(
17414    root: &Path,
17415    submodules: &[(String, std::path::PathBuf)],
17416    out: &mut String,
17417) {
17418    use std::fmt::Write as _;
17419    let count = submodules.len();
17420    out.push_str(r#"<div class="submodule-preview-strip">"#);
17421    write!(
17422        out,
17423        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>"#,
17424        if count == 1 { "" } else { "s" }
17425    )
17426    .ok();
17427    out.push_str(r#"<div class="submodule-preview-chips">"#);
17428    for (sub_name, sub_rel_path) in submodules {
17429        let sub_abs = root.join(sub_rel_path);
17430        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17431        let mut sub_stats = PreviewStats::default();
17432        let mut sub_rows: Vec<PreviewRow> = Vec::new();
17433        let mut sub_langs: Vec<&'static str> = Vec::new();
17434        let mut sub_budget = PreviewBudget {
17435            shown: 0,
17436            max_entries: 2000,
17437            max_depth: 9,
17438        };
17439        let mut sub_next_id = 1usize;
17440        let _ = collect_preview_rows(
17441            &sub_abs,
17442            &sub_abs,
17443            0,
17444            None,
17445            &mut sub_next_id,
17446            &mut sub_budget,
17447            &mut sub_stats,
17448            &mut sub_rows,
17449            &mut sub_langs,
17450            &[],
17451            &[],
17452        );
17453        let stats_json = format!(
17454            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17455            sub_stats.directories,
17456            sub_stats.files,
17457            sub_stats.supported,
17458            sub_stats.skipped,
17459            sub_stats.unsupported
17460        );
17461        write!(
17462            out,
17463            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>"#,
17464            escape_html(sub_name),
17465            escape_html(&sub_rel_path.to_string_lossy()),
17466            escape_html(&sub_size),
17467            escape_html(&stats_json),
17468            escape_html(sub_name),
17469            escape_html(&sub_size),
17470        )
17471        .ok();
17472    }
17473    out.push_str(
17474        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
17475    );
17476    out.push_str(r"</div>");
17477}
17478
17479/// Amber caution banner shown when the selected folder spans multiple independent
17480/// git repositories. Each repo is a one-click button that re-selects it as the
17481/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
17482fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17483    use std::fmt::Write as _;
17484    const MAX_LISTED: usize = 5;
17485    let total = layout.nested_repos.len();
17486
17487    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17488    if layout.root_is_repo {
17489        write!(
17490            out,
17491            r"<strong>Nested repositories detected</strong><p>This repository contains {total} nested git {} that are not registered submodules. Their files will be counted as part of this project. Submodules are fine — but if these are unrelated repositories, scan one repository at a time. Pick a repository to scan on its own:</p>",
17492            if total == 1 { "repository" } else { "repositories" }
17493        )
17494        .ok();
17495    } else {
17496        write!(
17497            out,
17498            r"<strong>Multiple repositories detected</strong><p>This folder contains {total} independent git repositories. oxide-sloc analyzes one repository at a time — git metrics and totals are only meaningful when the root is a single repository (submodules are fine). Pick one repository as the scan root:</p>"
17499        )
17500        .ok();
17501    }
17502
17503    out.push_str(r#"<div class="repo-pick-row">"#);
17504    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17505        let abs = root.join(rel);
17506        let abs_display = display_path(&abs);
17507        let label = rel.to_string_lossy().replace('\\', "/");
17508        write!(
17509            out,
17510            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17511            escape_html(&abs_display),
17512            escape_html(&label)
17513        )
17514        .ok();
17515    }
17516    if total > MAX_LISTED {
17517        write!(
17518            out,
17519            r#"<span class="repo-pick-more">and {} more</span>"#,
17520            total - MAX_LISTED
17521        )
17522        .ok();
17523    }
17524    out.push_str(r"</div>");
17525
17526    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17527    out.push_str(r"</div>");
17528}
17529
17530fn render_language_pills_row(languages: &[&str], out: &mut String) {
17531    use std::fmt::Write as _;
17532    if languages.is_empty() {
17533        out.push_str(
17534            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17535        );
17536        return;
17537    }
17538    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17539    for language in languages {
17540        if let Some(icon) = language_icon_file(language) {
17541            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();
17542        } else if let Some(svg) = language_inline_svg(language) {
17543            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();
17544        } else {
17545            write!(
17546                out,
17547                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17548                escape_html(&language.to_ascii_lowercase()),
17549                escape_html(language)
17550            )
17551            .ok();
17552        }
17553    }
17554}
17555
17556#[allow(clippy::too_many_lines)]
17557fn build_preview_html(
17558    root: &Path,
17559    include_patterns: &[String],
17560    exclude_patterns: &[String],
17561) -> Result<String> {
17562    if !root.exists() {
17563        return Ok(format!(
17564            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17565            escape_html(&display_path(root))
17566        ));
17567    }
17568
17569    let _selected = display_path(root);
17570    let mut stats = PreviewStats::default();
17571    let mut rows = Vec::new();
17572    let mut languages = Vec::new();
17573    let mut budget = PreviewBudget {
17574        shown: 0,
17575        max_entries: 600,
17576        max_depth: 9,
17577    };
17578    let mut next_row_id = 1usize;
17579
17580    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17581        || root.to_string_lossy().into_owned(),
17582        std::string::ToString::to_string,
17583    );
17584    let root_modified = root
17585        .metadata()
17586        .ok()
17587        .and_then(|meta| meta.modified().ok())
17588        .map_or_else(|| "-".to_string(), format_system_time);
17589
17590    rows.push(PreviewRow {
17591        row_id: 0,
17592        parent_row_id: None,
17593        depth: 0,
17594        name: format!("{root_name}/"),
17595        kind: PreviewKind::Dir,
17596        is_dir: true,
17597        language: None,
17598        modified: root_modified,
17599        type_label: "Directory".to_string(),
17600    });
17601    collect_preview_rows(
17602        root,
17603        root,
17604        0,
17605        Some(0),
17606        &mut next_row_id,
17607        &mut budget,
17608        &mut stats,
17609        &mut rows,
17610        &mut languages,
17611        include_patterns,
17612        exclude_patterns,
17613    )?;
17614
17615    let root_size = format_dir_size(dir_size_bytes(root));
17616
17617    let mut out = String::new();
17618    write!(
17619        out,
17620        r#"<div class="explorer-wrap" data-project-size="{}">"#,
17621        escape_html(&root_size)
17622    )
17623    .ok();
17624    out.push_str(r#"<div class="explorer-toolbar compact">"#);
17625    out.push_str(r#"<div class="explorer-title-group">"#);
17626    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17627    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17628    out.push_str(r"</div></div>");
17629
17630    out.push_str(r#"<div class="scope-stats">"#);
17631    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();
17632    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();
17633    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();
17634    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();
17635    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();
17636    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>"#);
17637    out.push_str(r"</div>");
17638
17639    let submodules = sloc_core::detect_submodules(root);
17640    if !submodules.is_empty() {
17641        render_submodule_chips(root, &submodules, &mut out);
17642    }
17643
17644    let repo_layout = sloc_core::detect_repository_layout(root);
17645    if repo_layout.has_multiple_repos() {
17646        render_multi_repo_warning(root, &repo_layout, &mut out);
17647    }
17648
17649    out.push_str(r#"<div class="scope-info-row">"#);
17650    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17651    render_language_pills_row(&languages, &mut out);
17652    out.push_str(r"</div></div>");
17653    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>"#);
17654    out.push_str(r"</div>");
17655
17656    out.push_str(r#"<div class="file-explorer-shell">"#);
17657    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>"#);
17658    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>"#);
17659    out.push_str(r#"<div class="file-explorer-tree">"#);
17660    for row in rows {
17661        let status_label = row.kind.label();
17662        let lang_attr = row.language.unwrap_or("");
17663        let toggle_html = if row.is_dir {
17664            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17665                .to_string()
17666        } else {
17667            r#"<span class="tree-bullet">•</span>"#.to_string()
17668        };
17669        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();
17670    }
17671    if budget.shown >= budget.max_entries {
17672        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>"#);
17673    }
17674    out.push_str(r"</div></div></div>");
17675
17676    Ok(out)
17677}
17678
17679#[derive(Default)]
17680struct PreviewStats {
17681    directories: usize,
17682    files: usize,
17683    supported: usize,
17684    skipped: usize,
17685    unsupported: usize,
17686}
17687
17688struct PreviewRow {
17689    row_id: usize,
17690    parent_row_id: Option<usize>,
17691    depth: usize,
17692    name: String,
17693    kind: PreviewKind,
17694    is_dir: bool,
17695    language: Option<&'static str>,
17696    modified: String,
17697    type_label: String,
17698}
17699
17700#[derive(Copy, Clone)]
17701enum PreviewKind {
17702    Dir,
17703    Supported,
17704    Skipped,
17705    Unsupported,
17706}
17707
17708impl PreviewKind {
17709    const fn filter_key(self) -> &'static str {
17710        match self {
17711            Self::Dir => "dir",
17712            Self::Supported => "supported",
17713            Self::Skipped => "skipped",
17714            Self::Unsupported => "unsupported",
17715        }
17716    }
17717
17718    const fn label(self) -> &'static str {
17719        match self {
17720            Self::Dir => "dir",
17721            Self::Supported => "supported",
17722            Self::Skipped => "skipped by policy",
17723            Self::Unsupported => "unsupported",
17724        }
17725    }
17726
17727    const fn badge_class(self) -> &'static str {
17728        match self {
17729            Self::Dir => "badge badge-dir",
17730            Self::Supported => "badge badge-scan",
17731            Self::Skipped => "badge badge-skip",
17732            Self::Unsupported => "badge badge-unsupported",
17733        }
17734    }
17735
17736    const fn node_class(self) -> &'static str {
17737        match self {
17738            Self::Dir => "tree-node-dir",
17739            Self::Supported => "tree-node-supported",
17740            Self::Skipped => "tree-node-skipped",
17741            Self::Unsupported => "tree-node-unsupported",
17742        }
17743    }
17744}
17745
17746struct PreviewBudget {
17747    shown: usize,
17748    max_entries: usize,
17749    max_depth: usize,
17750}
17751
17752/// Handle a single directory entry inside `collect_preview_rows`.
17753/// Returns `true` when the entry was handled (caller should `continue`).
17754#[allow(clippy::too_many_arguments)]
17755fn handle_preview_dir_entry(
17756    root: &Path,
17757    path: &Path,
17758    name: &str,
17759    modified: String,
17760    depth: usize,
17761    parent_row_id: Option<usize>,
17762    row_id: usize,
17763    next_row_id: &mut usize,
17764    budget: &mut PreviewBudget,
17765    stats: &mut PreviewStats,
17766    rows: &mut Vec<PreviewRow>,
17767    languages: &mut Vec<&'static str>,
17768    include_patterns: &[String],
17769    exclude_patterns: &[String],
17770) -> Result<()> {
17771    let relative = preview_relative_path(root, path);
17772    if should_skip_preview_directory(&relative, exclude_patterns) {
17773        return Ok(());
17774    }
17775    stats.directories += 1;
17776    rows.push(PreviewRow {
17777        row_id,
17778        parent_row_id,
17779        depth: depth + 1,
17780        name: format!("{name}/"),
17781        kind: PreviewKind::Dir,
17782        is_dir: true,
17783        language: None,
17784        modified,
17785        type_label: "Directory".to_string(),
17786    });
17787    budget.shown += 1;
17788    if !matches!(name, ".git" | "node_modules" | "target") {
17789        collect_preview_rows(
17790            root,
17791            path,
17792            depth + 1,
17793            Some(row_id),
17794            next_row_id,
17795            budget,
17796            stats,
17797            rows,
17798            languages,
17799            include_patterns,
17800            exclude_patterns,
17801        )?;
17802    }
17803    Ok(())
17804}
17805
17806/// Handle a single file entry inside `collect_preview_rows`.
17807#[allow(clippy::too_many_arguments)]
17808fn handle_preview_file_entry(
17809    root: &Path,
17810    path: &Path,
17811    name: &str,
17812    modified: String,
17813    depth: usize,
17814    parent_row_id: Option<usize>,
17815    row_id: usize,
17816    budget: &mut PreviewBudget,
17817    stats: &mut PreviewStats,
17818    rows: &mut Vec<PreviewRow>,
17819    languages: &mut Vec<&'static str>,
17820    include_patterns: &[String],
17821    exclude_patterns: &[String],
17822) {
17823    let relative = preview_relative_path(root, path);
17824    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17825        return;
17826    }
17827    stats.files += 1;
17828    let kind = classify_preview_file(name);
17829    match kind {
17830        PreviewKind::Supported => stats.supported += 1,
17831        PreviewKind::Skipped => stats.skipped += 1,
17832        PreviewKind::Unsupported => stats.unsupported += 1,
17833        PreviewKind::Dir => {}
17834    }
17835    let language = detect_language_name(name);
17836    if let Some(lang) = language
17837        && !languages.contains(&lang)
17838    {
17839        languages.push(lang);
17840    }
17841    rows.push(PreviewRow {
17842        row_id,
17843        parent_row_id,
17844        depth: depth + 1,
17845        name: name.to_owned(),
17846        kind,
17847        is_dir: false,
17848        language,
17849        modified,
17850        type_label: preview_type_label(name, language, kind),
17851    });
17852    budget.shown += 1;
17853}
17854
17855#[allow(clippy::too_many_arguments)]
17856#[allow(clippy::too_many_lines)]
17857fn collect_preview_rows(
17858    root: &Path,
17859    dir: &Path,
17860    depth: usize,
17861    parent_row_id: Option<usize>,
17862    next_row_id: &mut usize,
17863    budget: &mut PreviewBudget,
17864    stats: &mut PreviewStats,
17865    rows: &mut Vec<PreviewRow>,
17866    languages: &mut Vec<&'static str>,
17867    include_patterns: &[String],
17868    exclude_patterns: &[String],
17869) -> Result<()> {
17870    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17871        return Ok(());
17872    }
17873
17874    let mut entries = fs::read_dir(dir)
17875        .with_context(|| format!("failed to read directory {}", dir.display()))?
17876        .filter_map(std::result::Result::ok)
17877        .collect::<Vec<_>>();
17878    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17879
17880    for entry in entries {
17881        if budget.shown >= budget.max_entries {
17882            break;
17883        }
17884
17885        let path = entry.path();
17886        let name = entry.file_name().to_string_lossy().into_owned();
17887        let Ok(metadata) = entry.metadata() else {
17888            continue;
17889        };
17890        let row_id = *next_row_id;
17891        *next_row_id += 1;
17892        let modified = metadata
17893            .modified()
17894            .ok()
17895            .map_or_else(|| "-".to_string(), format_system_time);
17896
17897        if metadata.is_dir() {
17898            handle_preview_dir_entry(
17899                root,
17900                &path,
17901                &name,
17902                modified,
17903                depth,
17904                parent_row_id,
17905                row_id,
17906                next_row_id,
17907                budget,
17908                stats,
17909                rows,
17910                languages,
17911                include_patterns,
17912                exclude_patterns,
17913            )?;
17914            continue;
17915        }
17916
17917        if metadata.is_file() {
17918            handle_preview_file_entry(
17919                root,
17920                &path,
17921                &name,
17922                modified,
17923                depth,
17924                parent_row_id,
17925                row_id,
17926                budget,
17927                stats,
17928                rows,
17929                languages,
17930                include_patterns,
17931                exclude_patterns,
17932            );
17933        }
17934    }
17935
17936    Ok(())
17937}
17938
17939fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17940    if let Some(language) = language {
17941        return format!("{language} source");
17942    }
17943    let lower = name.to_ascii_lowercase();
17944    let ext = Path::new(&lower)
17945        .extension()
17946        .and_then(|e| e.to_str())
17947        .unwrap_or("");
17948    match kind {
17949        PreviewKind::Skipped => {
17950            if lower.ends_with(".min.js") {
17951                "Minified asset".to_string()
17952            } else if [
17953                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17954            ]
17955            .contains(&ext)
17956            {
17957                "Binary or archive".to_string()
17958            } else {
17959                "Skipped file".to_string()
17960            }
17961        }
17962        PreviewKind::Unsupported => {
17963            if ext.is_empty() {
17964                "Unsupported file".to_string()
17965            } else {
17966                format!("{} file", ext.to_ascii_uppercase())
17967            }
17968        }
17969        PreviewKind::Supported => "Supported source".to_string(),
17970        PreviewKind::Dir => "Directory".to_string(),
17971    }
17972}
17973
17974fn format_system_time(time: SystemTime) -> String {
17975    #[allow(clippy::cast_possible_wrap)]
17976    let secs = match time.duration_since(UNIX_EPOCH) {
17977        Ok(duration) => duration.as_secs() as i64,
17978        Err(_) => return "-".to_string(),
17979    };
17980    let days = secs.div_euclid(86_400);
17981    let secs_of_day = secs.rem_euclid(86_400);
17982    let (year, month, day) = civil_from_days(days);
17983    let hour = secs_of_day / 3_600;
17984    let minute = (secs_of_day % 3_600) / 60;
17985    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17986}
17987
17988#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17989fn civil_from_days(days: i64) -> (i32, u32, u32) {
17990    let z = days + 719_468;
17991    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17992    let doe = z - era * 146_097;
17993    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17994    let y = yoe + era * 400;
17995    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17996    let mp = (5 * doy + 2) / 153;
17997    let d = doy - (153 * mp + 2) / 5 + 1;
17998    let m = mp + if mp < 10 { 3 } else { -9 };
17999    let year = y + i64::from(m <= 2);
18000    (year as i32, m as u32, d as u32)
18001}
18002
18003// The input is already lowercased via `to_ascii_lowercase()` before calling
18004// `ends_with`, so the comparisons are inherently case-insensitive.
18005#[allow(clippy::case_sensitive_file_extension_comparisons)]
18006fn detect_language_name(name: &str) -> Option<&'static str> {
18007    let lower = name.to_ascii_lowercase();
18008    if lower.ends_with(".c") || lower.ends_with(".h") {
18009        Some("C")
18010    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
18011        .iter()
18012        .any(|s| lower.ends_with(s))
18013    {
18014        Some("C++")
18015    } else if lower.ends_with(".cs") {
18016        Some("C#")
18017    } else if lower.ends_with(".py") {
18018        Some("Python")
18019    } else if lower.ends_with(".sh") {
18020        Some("Shell")
18021    } else if [".ps1", ".psm1", ".psd1"]
18022        .iter()
18023        .any(|s| lower.ends_with(s))
18024    {
18025        Some("PowerShell")
18026    } else {
18027        None
18028    }
18029}
18030
18031fn language_icon_file(language: &str) -> Option<&'static str> {
18032    match language {
18033        "C" => Some("c.png"),
18034        "C++" => Some("cpp.png"),
18035        "C#" => Some("c-sharp.png"),
18036        "Python" => Some("python.png"),
18037        "Shell" => Some("shell.png"),
18038        "PowerShell" => Some("powershell.png"),
18039        "JavaScript" => Some("java-script.png"),
18040        "HTML" => Some("html-5.png"),
18041        "Java" => Some("java.png"),
18042        "Visual Basic" => Some("visual-basic.png"),
18043        "Assembly" => Some("asm.png"),
18044        "Go" => Some("go.png"),
18045        "R" => Some("r.png"),
18046        "XML" => Some("xml.png"),
18047        "Groovy" => Some("groovy.png"),
18048        "Dockerfile" => Some("docker.png"),
18049        "Makefile" => Some("makefile.svg"),
18050        "Perl" => Some("perl.svg"),
18051        _ => None,
18052    }
18053}
18054
18055// Inline SVG badges for languages that have no PNG icon in images/icons/.
18056// Using inline SVG keeps the web UI fully self-contained — no extra files
18057// needed on disk, no 404s on air-gapped deployments.
18058// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
18059fn language_inline_svg(language: &str) -> Option<&'static str> {
18060    match language {
18061        "Rust" => Some(
18062            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>"##,
18063        ),
18064        "TypeScript" => Some(
18065            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>"##,
18066        ),
18067        _ => None,
18068    }
18069}
18070
18071// The input is already lowercased via `to_ascii_lowercase()` before the
18072// `ends_with` calls, so these comparisons are inherently case-insensitive.
18073#[allow(clippy::case_sensitive_file_extension_comparisons)]
18074fn classify_preview_file(name: &str) -> PreviewKind {
18075    let lower = name.to_ascii_lowercase();
18076
18077    let scannable = [
18078        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
18079        ".psm1", ".psd1",
18080    ]
18081    .iter()
18082    .any(|suffix| lower.ends_with(suffix));
18083
18084    if scannable {
18085        PreviewKind::Supported
18086    } else if lower.ends_with(".min.js")
18087        || lower.ends_with(".lock")
18088        || lower.ends_with(".png")
18089        || lower.ends_with(".jpg")
18090        || lower.ends_with(".jpeg")
18091        || lower.ends_with(".gif")
18092        || lower.ends_with(".zip")
18093        || lower.ends_with(".pdf")
18094        || lower.ends_with(".pyc")
18095        || lower.ends_with(".xz")
18096        || lower.ends_with(".tar")
18097        || lower.ends_with(".gz")
18098    {
18099        PreviewKind::Skipped
18100    } else {
18101        PreviewKind::Unsupported
18102    }
18103}
18104
18105fn preview_relative_path(root: &Path, path: &Path) -> String {
18106    path.strip_prefix(root)
18107        .ok()
18108        .unwrap_or(path)
18109        .to_string_lossy()
18110        .replace('\\', "/")
18111        .trim_matches('/')
18112        .to_string()
18113}
18114
18115fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
18116    if relative.is_empty() {
18117        return false;
18118    }
18119
18120    exclude_patterns.iter().any(|pattern| {
18121        wildcard_match(pattern, relative)
18122            || wildcard_match(pattern, &format!("{relative}/"))
18123            || wildcard_match(pattern, &format!("{relative}/placeholder"))
18124    })
18125}
18126
18127fn should_include_preview_file(
18128    relative: &str,
18129    include_patterns: &[String],
18130    exclude_patterns: &[String],
18131) -> bool {
18132    if relative.is_empty() {
18133        return true;
18134    }
18135
18136    let included = include_patterns.is_empty()
18137        || include_patterns
18138            .iter()
18139            .any(|pattern| wildcard_match(pattern, relative));
18140    let excluded = exclude_patterns
18141        .iter()
18142        .any(|pattern| wildcard_match(pattern, relative));
18143
18144    included && !excluded
18145}
18146
18147fn wildcard_match(pattern: &str, candidate: &str) -> bool {
18148    let pattern = pattern.trim().replace('\\', "/");
18149    let candidate = candidate.trim().replace('\\', "/");
18150    let p = pattern.as_bytes();
18151    let c = candidate.as_bytes();
18152    let mut pi = 0usize;
18153    let mut ci = 0usize;
18154    let mut star: Option<usize> = None;
18155    let mut star_match = 0usize;
18156
18157    while ci < c.len() {
18158        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
18159            pi += 1;
18160            ci += 1;
18161        } else if pi < p.len() && p[pi] == b'*' {
18162            while pi < p.len() && p[pi] == b'*' {
18163                pi += 1;
18164            }
18165            star = Some(pi);
18166            star_match = ci;
18167        } else if let Some(star_pi) = star {
18168            star_match += 1;
18169            ci = star_match;
18170            pi = star_pi;
18171        } else {
18172            return false;
18173        }
18174    }
18175
18176    while pi < p.len() && p[pi] == b'*' {
18177        pi += 1;
18178    }
18179
18180    pi == p.len()
18181}
18182
18183fn escape_html(value: &str) -> String {
18184    value
18185        .replace('&', "&amp;")
18186        .replace('<', "&lt;")
18187        .replace('>', "&gt;")
18188        .replace('"', "&quot;")
18189        .replace('\'', "&#39;")
18190}
18191
18192#[derive(Clone)]
18193struct SubmoduleRow {
18194    name: String,
18195    relative_path: String,
18196    files_analyzed: u64,
18197    code_lines: u64,
18198    comment_lines: u64,
18199    blank_lines: u64,
18200    total_physical_lines: u64,
18201    html_url: Option<String>,
18202}
18203
18204#[derive(Template)]
18205#[template(
18206    source = r##"
18207<!doctype html>
18208<html lang="en">
18209<head>
18210  <meta charset="utf-8">
18211  <title>OxideSLOC | tmp-sloc</title>
18212  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18213  <style nonce="{{ csp_nonce }}">
18214    :root {
18215      --bg: #efe9e2;
18216      --surface: #fcfaf7;
18217      --surface-2: #f7f0e8;
18218      --surface-3: #efe3d5;
18219      --line: #dfcfbf;
18220      --line-strong: #cfb29c;
18221      --text: #2f241c;
18222      --muted: #6f6257;
18223      --muted-2: #917f71;
18224      --nav: #b85d33;
18225      --nav-2: #7a371b;
18226      --accent: #2563eb;
18227      --accent-2: #1d4ed8;
18228      --oxide: #b85d33;
18229      --oxide-2: #8f4220;
18230      --success-bg: #eaf9ee;
18231      --success-text: #1c8746;
18232      --warn-bg: #fff2d8;
18233      --warn-text: #926000;
18234      --danger-bg: #fdeaea;
18235      --danger-text: #b33b3b;
18236      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18237      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18238      --radius: 14px;
18239    }
18240
18241    body.dark-theme {
18242      --bg: #1b1511;
18243      --surface: #261c17;
18244      --surface-2: #2d221d;
18245      --surface-3: #372922;
18246      --line: #524238;
18247      --line-strong: #6c5649;
18248      --text: #f5ece6;
18249      --muted: #c7b7aa;
18250      --muted-2: #aa9485;
18251      --nav: #b85d33;
18252      --nav-2: #7a371b;
18253      --accent: #6f9bff;
18254      --accent-2: #4a78ee;
18255      --oxide: #d37a4c;
18256      --oxide-2: #b35428;
18257      --success-bg: #163927;
18258      --success-text: #8fe2a8;
18259      --warn-bg: #3c2d11;
18260      --warn-text: #f3cb75;
18261      --danger-bg: #3d1f1f;
18262      --danger-text: #ff9f9f;
18263      --shadow: 0 14px 28px rgba(0,0,0,0.28);
18264      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18265    }
18266
18267    * { box-sizing: border-box; }
18268    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); }
18269    html { overflow-y: scroll; }
18270    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18271    .top-nav, .page, .loading { position: relative; z-index: 2; }
18272    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18273    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18274    .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); }
18275    .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; }
18276    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18277    .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)); }
18278    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18279    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18280    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18281    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18282    .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; }
18283    .nav-project-pill.visible { display:inline-flex; }
18284    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18285    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18286    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18287    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18288    @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; } }
18289    .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; }
18290    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18291    .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; }
18292    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18293    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18294    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18295    .theme-toggle .icon-sun { display:none; }
18296    body.dark-theme .theme-toggle .icon-sun { display:block; }
18297    body.dark-theme .theme-toggle .icon-moon { display:none; }
18298    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
18299    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18300    .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);}
18301    .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;}
18302    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18303    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18304    .settings-modal-body{padding:14px 16px 16px;}
18305    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18306    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18307    .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;}
18308    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18309    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18310    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18311    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18312    .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;}
18313    .tz-select:focus{border-color:var(--oxide);}
18314    .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; }
18315    .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;}
18316    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18317    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18318    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18319    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18320    .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; }
18321    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18322    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18323    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18324    .wb-stats-header { padding: 10px 24px 0; }
18325    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18326    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18327    .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; }
18328    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18329    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18330    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18331    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18332    .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; }
18333    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18334    .ws-stat-analyzers { position: relative; }
18335    .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; }
18336    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18337    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18338    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18339    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18340    .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; }
18341    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18342    .ws-divider { display: none; }
18343    .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%; }
18344    .ws-path-link:hover { color:var(--oxide); }
18345    body.dark-theme .ws-path-link { color:var(--oxide); }
18346    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18347    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18348    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18349    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18350    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18351    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18352    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18353    .ws-mini-box-lg { flex:2 1 0; }
18354    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18355    .ws-mini-box-br { flex:1.5 1 0; }
18356    .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; }
18357    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18358    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18359    #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; }
18360    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18361    .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; }
18362    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18363    .git-source-banner strong { font-weight:800; color:var(--text); }
18364    .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; }
18365    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18366    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18367    .git-source-banner a:hover { text-decoration:underline; }
18368    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18369    .path-scope-sep { background:var(--line); margin:4px 14px; }
18370    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18371    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18372    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18373    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18374    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18375    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18376    .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; }
18377    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18378    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18379    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18380    .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; }
18381    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18382    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18383    [data-wb-tip] { cursor:help; }
18384    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18385    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18386    .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; }
18387    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18388    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18389    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18390    .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; }
18391    .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); }
18392    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18393    .side-info-card { padding: 18px; }
18394    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18395    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18396    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18397    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18398    .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); }
18399    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18400    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18401    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18402    .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; }
18403    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18404    .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; }
18405    .side-stack::-webkit-scrollbar { display: none; }
18406    .step-nav { padding: 20px 16px; }
18407    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18408    .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; }
18409    .step-button:hover { background: var(--surface-2); }
18410    .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); }
18411    .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; }
18412    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18413    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18414    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18415    .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); }
18416    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18417    .step-nav-sum-row:last-child { border-bottom:none; }
18418    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18419    .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; }
18420    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18421    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18422    .quick-scan-section { padding: 10px 4px 14px; }
18423    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18424    .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; }
18425    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18426    .quick-scan-btn:active { transform:translateY(0); }
18427    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18428    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18429    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18430    @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);} }
18431    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18432    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18433    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18434    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18435    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18436    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18437    .step-button.done .step-check { opacity:1; }
18438    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18439    .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; }
18440    .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; }
18441    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18442    .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; }
18443    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18444    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18445    .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; }
18446    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18447    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18448    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18449    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18450    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18451    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18452    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18453    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18454    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18455    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18456    .card-body { padding: 22px; }
18457    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18458    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18459    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18460    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18461    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18462    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18463    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18464    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18465    .field { min-width:0; }
18466    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18467    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; }
18468    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); }
18469    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18470    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); }
18471    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18472    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18473    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18474    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18475    .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; }
18476    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18477    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18478    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18479    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18480    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18481    .input-group.compact { grid-template-columns: 1fr auto auto; }
18482    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18483    .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)); }
18484    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18485    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18486    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18487    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18488    .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; }
18489    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18490    .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; }
18491    .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); }
18492    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18493    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18494    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18495    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18496    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18497    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18498    @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; } }
18499    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18500    button.secondary { background: var(--surface); }
18501    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18502    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18503    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18504    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18505    .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); }
18506    .section + .wizard-actions { border-top: none; padding-top: 0; }
18507    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18508    .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; }
18509    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18510    .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; }
18511    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18512    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18513    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18514    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18515    .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); }
18516    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18517    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18518    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18519    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18520    .field-help-grid.coupled-help { margin-top: 12px; }
18521    .field-help-grid.preset-grid { align-items: start; }
18522    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18523    .preset-inline-row .field { margin: 0; }
18524    .preset-inline-row .explainer-card { margin: 0; }
18525    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18526    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18527    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18528    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18529    .preset-kv-row > :last-child { flex:1; min-width:0; }
18530    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18531    .output-field-row .field { margin: 0; }
18532    .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; }
18533    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18534    .step3-subtitle { margin-bottom: 10px; max-width: none; }
18535    .counting-intro { margin-bottom: 8px; max-width: none; }
18536    .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; }
18537    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18538    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18539    .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; }
18540    .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; }
18541    .section-spacer-top { margin-top: 28px; }
18542    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18543    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18544    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18545    .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); }
18546    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18547    .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; }
18548    .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; }
18549    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18550    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18551    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18552    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18553    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18554    .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; }
18555    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18556    .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); }
18557    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; }
18558    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; }
18559    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18560    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18561    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18562    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18563    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18564    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18565    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18566    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18567    .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); }
18568    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18569    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18570    .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; }
18571    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18572    .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; }
18573    .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; }
18574    .always-tracked-tip-body { flex:1; min-width:0; }
18575    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18576    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18577    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18578    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18579    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18580    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18581    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18582    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18583    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18584    .advanced-rule-description strong { color: var(--text); }
18585    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18586    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18587    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18588    .review-link:hover { text-decoration: underline; }
18589    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18590    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18591    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18592    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18593    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18594    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18595    .review-card ul { padding-left: 18px; margin: 0; }
18596    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18597    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18598    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18599    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18600    .review-card { min-height: 0; }
18601    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18602    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18603    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18604    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18605    .lang-overflow-chip { position:relative; cursor:default; }
18606    .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; }
18607    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18608    .git-inline-row { align-items:start; }
18609    .mixed-line-card { display:flex; flex-direction:column; }
18610    .preset-inline-row .toggle-card { justify-content: center; }
18611        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18612    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18613    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18614    .explorer-title { font-size: 18px; font-weight: 850; }
18615    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18616    .explorer-subtitle.wide { max-width: none; }
18617    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18618    .better-spacing { align-items:flex-start; justify-content:flex-end; }
18619    .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; }
18620    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18621    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18622    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18623    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18624    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18625    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18626    .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; }
18627    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18628    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18629    .scope-stat-button.supported { background: var(--success-bg); }
18630    .scope-stat-button.skipped { background: var(--warn-bg); }
18631    .scope-stat-button.unsupported { background: var(--danger-bg); }
18632    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18633    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18634    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18635    [data-tooltip] { position: relative; }
18636    [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); }
18637    [data-tooltip]:hover::after { display: block; }
18638    .scope-stat-button[data-tooltip] { cursor: pointer; }
18639    .badge[data-tooltip] { cursor: help; }
18640    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18641    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18642    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18643    .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; }
18644    .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; }
18645    code { display:inline-block; margin-top:0; padding:2px 7px; }
18646    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18647    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18648    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18649    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18650    .language-pill.muted-pill { color: var(--muted); }
18651    button.language-pill { appearance:none; cursor:pointer; }
18652    .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); }
18653    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18654    .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; }
18655    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18656    .file-explorer-search-row { margin-left: auto; }
18657    .explorer-filter-select { min-width: 170px; width: 170px; }
18658    .explorer-search { min-width: 300px; width: 300px; }
18659    .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); }
18660    .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; }
18661    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18662    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18663    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18664    .file-explorer-tree { max-height: 640px; overflow:auto; }
18665    .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); }
18666    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18667    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18668    .tree-row.hidden-by-filter { display:none !important; }
18669    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18670    .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; }
18671    .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; }
18672    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18673    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18674    .tree-node { display:inline-flex; align-items:center; min-width:0; }
18675    .tree-node-dir { color: var(--text); font-weight: 800; }
18676    .tree-node-supported { color: var(--success-text); }
18677    .tree-node-skipped { color: var(--warn-text); }
18678    .tree-node-unsupported { color: var(--danger-text); }
18679    .tree-node-more { color: var(--muted-2); font-style: italic; }
18680    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18681    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18682    .tree-status-cell { display:flex; justify-content:flex-start; }
18683    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18684    .preview-warning { color: var(--warn-text); background: var(--warn-bg); border:1px solid var(--warn-text); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; font-size: 13px; line-height: 1.5; }
18685    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18686    .preview-warning p { margin: 0 0 10px; }
18687    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18688    .repo-pick { font-family: inherit; font-size: 12px; font-weight: 600; color: var(--warn-text); background: transparent; border:1px solid var(--warn-text); border-radius: 999px; padding: 4px 12px; cursor: pointer; transition: background .15s ease, color .15s ease; }
18689    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18690    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18691    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18692    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18693    .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; }
18694    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18695    .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; }
18696    @keyframes prevSpin { to { transform:rotate(360deg); } }
18697    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18698    .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; }
18699    .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; }
18700    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18701    .preview-gate-info svg { width:16px; height:16px; }
18702    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18703    @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); } }
18704    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18705    .preview-loading-text { flex:1; min-width:0; }
18706    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18707    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18708    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18709    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18710    .cov-scan-idle { display:none; }
18711    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18712    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18713    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18714    .cov-scan-title { font-weight:600; font-size:12.5px; }
18715    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18716    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18717    .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; }
18718    .cov-scan-use:hover { opacity:.75; }
18719    .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; }
18720    .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; }
18721    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18722    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18723    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18724    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18725    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18726    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18727    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18728    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18729    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18730    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18731    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18732    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18733    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18734    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18735    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18736    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18737    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18738    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18739    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18740    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18741    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18742    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18743    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18744    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18745    .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); }
18746    .loading.active { display:flex; }
18747    /* Lock page scroll while the analysis modal is open so the removed scrollbar
18748       gutter doesn't pull the centered card slightly left of true center. */
18749    body.modal-open { overflow: hidden; }
18750    .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; }
18751    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18752    .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; }
18753    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18754    .loading-card > * { position:relative; z-index:1; }
18755    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18756    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%); }
18757    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18758    .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; }
18759    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18760    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18761    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18762    .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; }
18763    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18764    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18765    .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; }
18766    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18767    .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; }
18768    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18769    .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; }
18770    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18771    .lc-step.done { color:var(--muted);opacity:0.55; }
18772    .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; }
18773    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18774    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18775    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18776    .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; }
18777    .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; }
18778    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18779    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18780    .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; }
18781    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18782    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18783    .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; }
18784    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18785    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18786    .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; }
18787    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18788    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18789    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18790    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18791    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18792    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18793    .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; }
18794    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18795    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18796    .hidden { display:none !important; }
18797    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18798    .site-footer a{color:var(--muted);}
18799    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18800    @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; } }
18801    .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;}
18802    @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));}}
18803    .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;}
18804    .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; }
18805    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18806    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18807    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18808    .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; }
18809    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18810    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18811    .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; }
18812    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18813    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18814    .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; }
18815    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18816    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18817    .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; }
18818    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18819    .info-icon-btn:hover { color:var(--text); }
18820    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); }
18821    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18822    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18823    .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;}
18824    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18825    .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;}
18826    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18827    #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);}
18828    #offline-file-banner.show{display:flex;}
18829    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18830    #offline-file-banner .ofb-text{flex:1;}
18831    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18832    #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;}
18833    #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;}
18834    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18835    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18836    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18837    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18838    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18839    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18840    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18841  </style>
18842</head>
18843<body id="page-top">
18844  <div id="offline-file-banner" role="alert">
18845    <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>
18846    <span class="ofb-text">
18847      Charts, images, and navigation require the oxide-sloc server.
18848      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18849      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18850      The metric tables below are fully readable without the server.
18851    </span>
18852    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18853  </div>
18854  <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>
18855  <div class="background-watermarks" aria-hidden="true">
18856    <img src="/images/logo/logo-text.png" alt="" />
18857    <img src="/images/logo/logo-text.png" alt="" />
18858    <img src="/images/logo/logo-text.png" alt="" />
18859    <img src="/images/logo/logo-text.png" alt="" />
18860    <img src="/images/logo/logo-text.png" alt="" />
18861    <img src="/images/logo/logo-text.png" alt="" />
18862    <img src="/images/logo/logo-text.png" alt="" />
18863    <img src="/images/logo/logo-text.png" alt="" />
18864    <img src="/images/logo/logo-text.png" alt="" />
18865    <img src="/images/logo/logo-text.png" alt="" />
18866    <img src="/images/logo/logo-text.png" alt="" />
18867    <img src="/images/logo/logo-text.png" alt="" />
18868    <img src="/images/logo/logo-text.png" alt="" />
18869    <img src="/images/logo/logo-text.png" alt="" />
18870  </div>
18871  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18872  <div class="top-nav">
18873    <div class="top-nav-inner">
18874      <a class="brand" href="/">
18875        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18876        <div class="brand-copy">
18877          <div class="brand-title">OxideSLOC</div>
18878          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18879        </div>
18880      </a>
18881      <div class="nav-project-slot">
18882        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18883          <span class="nav-project-label">Project</span>
18884          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18885        </div>
18886      </div>
18887      <div class="nav-status">
18888        <a class="nav-pill" href="/">Home</a>
18889        <div class="nav-dropdown">
18890          <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>
18891          <div class="nav-dropdown-menu">
18892            <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>
18893          </div>
18894        </div>
18895        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18896        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18897        <div class="nav-dropdown">
18898          <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>
18899          <div class="nav-dropdown-menu">
18900            <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>
18901          </div>
18902        </div>
18903        <div class="server-status-wrap" id="server-status-wrap">
18904          <div class="nav-pill server-online-pill" id="server-status-pill">
18905            <span class="status-dot" id="status-dot"></span>
18906            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18907            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18908          </div>
18909          <div class="server-status-tip">
18910            {% if server_mode %}
18911            OxideSLOC is running in server mode — accessible on your LAN.
18912            {% else %}
18913            OxideSLOC is running locally — only accessible from this machine.
18914            {% endif %}
18915            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18916          </div>
18917        </div>
18918        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18919          <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>
18920        </button>
18921        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18922          <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>
18923          <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>
18924        </button>
18925      </div>
18926    </div>
18927  </div>
18928
18929  <div class="loading" id="loading">
18930    <div class="loading-card" id="loading-card">
18931      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18932      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18933      <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>
18934      <div class="lc-steps" id="lc-steps">
18935        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18936        <div class="lc-step-arrow">›</div>
18937        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18938        <div class="lc-step-arrow">›</div>
18939        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18940        <div class="lc-step-arrow">›</div>
18941        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18942      </div>
18943      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18944      <div class="lc-metrics" id="lc-metrics">
18945        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18946        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18947        <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>
18948        <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>
18949      </div>
18950      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18951      <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>
18952      <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>
18953      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18954      <div class="lc-actions hidden" id="lc-actions">
18955        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18956        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18957      </div>
18958      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18959        <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>
18960        Cancel scan
18961      </button>
18962    </div>
18963  </div>
18964
18965  <div class="page">
18966    <div class="workbench-strip">
18967      <div class="workbench-box wb-stats">
18968        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18969          <span class="wb-stats-title">Analysis session</span>
18970        </div>
18971        <div class="ws-left">
18972          <div class="ws-stat ws-stat-analyzers">
18973            <span class="ws-label">Analyzers</span>
18974            <span class="ws-value">
18975              <span class="ws-badge">60 languages</span>
18976            </span>
18977            <div class="ws-lang-tooltip">
18978              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18979              <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>
18980              <div class="ws-lang-grid">
18981                <span class="ws-lang-item">Assembly</span>
18982                <span class="ws-lang-item">C</span>
18983                <span class="ws-lang-item">C++</span>
18984                <span class="ws-lang-item">C#</span>
18985                <span class="ws-lang-item">Clojure</span>
18986                <span class="ws-lang-item">CSS</span>
18987                <span class="ws-lang-item">Dart</span>
18988                <span class="ws-lang-item">Dockerfile</span>
18989                <span class="ws-lang-item">Elixir</span>
18990                <span class="ws-lang-item">Erlang</span>
18991                <span class="ws-lang-item">F#</span>
18992                <span class="ws-lang-item">Go</span>
18993                <span class="ws-lang-item">Groovy</span>
18994                <span class="ws-lang-item">Haskell</span>
18995                <span class="ws-lang-item">HTML</span>
18996                <span class="ws-lang-item">Java</span>
18997                <span class="ws-lang-item">JavaScript</span>
18998                <span class="ws-lang-item">Julia</span>
18999                <span class="ws-lang-item">Kotlin</span>
19000                <span class="ws-lang-item">Lua</span>
19001                <span class="ws-lang-item">Makefile</span>
19002                <span class="ws-lang-item">Nim</span>
19003                <span class="ws-lang-item">Obj-C</span>
19004                <span class="ws-lang-item">OCaml</span>
19005                <span class="ws-lang-item">Perl</span>
19006                <span class="ws-lang-item">PHP</span>
19007                <span class="ws-lang-item">PowerShell</span>
19008                <span class="ws-lang-item">Python</span>
19009                <span class="ws-lang-item">R</span>
19010                <span class="ws-lang-item">Ruby</span>
19011                <span class="ws-lang-item">Rust</span>
19012                <span class="ws-lang-item">Scala</span>
19013                <span class="ws-lang-item">SCSS</span>
19014                <span class="ws-lang-item">Shell</span>
19015                <span class="ws-lang-item">SQL</span>
19016                <span class="ws-lang-item">Svelte</span>
19017                <span class="ws-lang-item">Swift</span>
19018                <span class="ws-lang-item">TypeScript</span>
19019                <span class="ws-lang-item">Vue</span>
19020                <span class="ws-lang-item">XML</span>
19021                <span class="ws-lang-item">Zig</span>
19022                <span class="ws-lang-item">Solidity</span>
19023                <span class="ws-lang-item">Protobuf</span>
19024                <span class="ws-lang-item">HCL</span>
19025                <span class="ws-lang-item">GraphQL</span>
19026                <span class="ws-lang-item">Ada</span>
19027                <span class="ws-lang-item">VHDL</span>
19028                <span class="ws-lang-item">Verilog</span>
19029                <span class="ws-lang-item">Tcl</span>
19030                <span class="ws-lang-item">Pascal</span>
19031                <span class="ws-lang-item">Visual Basic</span>
19032                <span class="ws-lang-item">Lisp</span>
19033                <span class="ws-lang-item">Fortran</span>
19034                <span class="ws-lang-item">Nix</span>
19035                <span class="ws-lang-item">Crystal</span>
19036                <span class="ws-lang-item">D</span>
19037                <span class="ws-lang-item">GLSL</span>
19038                <span class="ws-lang-item">CMake</span>
19039                <span class="ws-lang-item">Elm</span>
19040                <span class="ws-lang-item">Awk</span>
19041              </div>
19042            </div>
19043          </div>
19044          <div class="ws-divider"></div>
19045          <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>
19046          <div class="ws-divider"></div>
19047          <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts — JSON, HTML, and PDF reports — are written after each completed scan.">
19048            <span class="ws-label">Output</span>
19049            <span class="ws-value">
19050              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
19051                <span id="ws-output-root">project/sloc</span>
19052              </button>
19053            </span>
19054          </div>
19055        </div>
19056      </div>
19057      <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.">
19058        <div class="ws-history-label">Scan history</div>
19059        <div class="ws-history-inner">
19060          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
19061            <div class="ws-mini-label">Scans</div>
19062            <div class="ws-mini-value" id="ws-scan-count">—</div>
19063          </div>
19064          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
19065            <div class="ws-mini-label">Last Scan</div>
19066            <div class="ws-mini-value" id="ws-last-scan">—</div>
19067          </div>
19068          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
19069            <div class="ws-mini-label">Branch</div>
19070            <div class="ws-mini-value" id="ws-branch">—</div>
19071          </div>
19072        </div>
19073      </div>
19074    </div>
19075
19076    <div class="layout">
19077      <aside class="side-stack">
19078        <section class="step-nav">
19079        <h3>Guided scan setup</h3>
19080        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
19081          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
19082          Top of page
19083        </a>
19084        <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>
19085        <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>
19086        <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>
19087        <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>
19088
19089        <div class="step-steps-divider"></div>
19090
19091        <div class="step-nav-info" id="step-nav-info">
19092          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
19093          <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>
19094        </div>
19095
19096        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
19097          <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>
19098          <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>
19099          <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>
19100        </div>
19101
19102        <div class="quick-scan-divider"></div>
19103        <div class="quick-scan-section">
19104          <div class="quick-scan-label">No customization needed?</div>
19105          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
19106            <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>
19107            Quick Scan
19108          </button>
19109          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
19110        </div>
19111
19112        <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>
19113        <div class="sidebar-scroll-divider"></div>
19114        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
19115          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
19116          Skip to bottom
19117        </a>
19118        </section>
19119
19120      </aside>
19121
19122      <section class="card">
19123        <div class="card-header">
19124          <div class="card-title-row">
19125            <div>
19126              <h1 class="card-title">Guided scan configuration</h1>
19127              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
19128            </div>
19129            <div class="wizard-progress" aria-label="Scan setup progress">
19130              <div class="wizard-progress-top">
19131                <span class="wizard-progress-label">Setup progress</span>
19132                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
19133              </div>
19134              <div class="wizard-progress-track">
19135                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
19136              </div>
19137            </div>
19138          </div>
19139        </div>
19140        <div class="card-body">
19141          <form method="post" action="/analyze" id="analyze-form">
19142            <div class="wizard-step active" data-step="1">
19143              <div class="section">
19144                <div class="section-kicker">Step 1</div>
19145                <h2>Select project and preview scope</h2>
19146                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
19147                <div class="field">
19148                  <label for="path">Project path</label>
19149                  {% if !git_repo.is_empty() %}
19150                  <div class="git-source-banner">
19151                    <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>
19152                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
19153                    <a href="/git-browser">← Back to Git Browser</a>
19154                  </div>
19155                  {% endif %}
19156                  <div class="path-scope-grid">
19157                      {% if !git_repo.is_empty() %}
19158                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
19159                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
19160                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
19161                      {% else %}
19162                      <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
19163                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
19164                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
19165                      {% endif %}
19166                    <div class="path-scope-sep"></div>
19167                    <div class="scope-legend-row">
19168                      <span class="scope-legend-label">Scope legend:</span>
19169                      <span class="scope-legend-badges">
19170                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
19171                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
19172                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
19173                      </span>
19174                    </div>
19175                  </div>
19176                  {% if git_repo.is_empty() %}
19177                  {% if server_mode %}
19178                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
19179                    ℹ️ Files are compressed and streamed — no fixed size limit.
19180                  </div>
19181                  {% endif %}
19182                  <div class="path-info-row">
19183                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
19184                      <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>
19185                      <span id="project-size-text">Project size: —</span>
19186                    </button>
19187                  </div>
19188                  {% else %}
19189                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
19190                  {% endif %}
19191                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
19192                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
19193                </div>
19194
19195                <div class="scope-preview-divider" aria-hidden="true"></div>
19196
19197                <div id="preview-panel">
19198                  <div class="preview-error">Loading preview...</div>
19199                </div>
19200              </div>
19201
19202              <div class="section" style="margin-top:14px;">
19203                <div class="preset-inline-row git-inline-row">
19204                  <div class="toggle-card" style="margin:0;">
19205                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
19206                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
19207                    <label class="checkbox">
19208                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
19209                      <div>
19210                        <span>Detect and separate git submodules</span>
19211                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
19212                      </div>
19213                    </label>
19214                  </div>
19215                  <div class="explainer-card prominent" style="margin:0;">
19216                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19217                    <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>
19218                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
19219    path = libs/core
19220    url  = https://github.com/org/core.git
19221
19222[submodule "libs/ui"]
19223    path = libs/ui
19224    url  = https://github.com/org/ui.git</div>
19225                  </div>
19226                </div>
19227              </div>
19228
19229              <div class="section">
19230                <div class="field-grid">
19231                  <div class="field">
19232                    <div class="glob-label-row">
19233                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19234                      <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>
19235                    </div>
19236                    <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>
19237                    <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>
19238                  </div>
19239                  <div class="field">
19240                    <div class="glob-label-row">
19241                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19242                    </div>
19243                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
19244                    <div id="quick-exclude-chips" class="quick-excl-row">
19245                      <span class="quick-excl-label">Quick add:</span>
19246                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19247                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19248                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19249                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19250                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19251                      <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>
19252                    </div>
19253                    <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>
19254                  </div>
19255                </div>
19256                <div class="glob-guidance-grid">
19257                  <div class="glob-guidance-card">
19258                    <strong>How to read them</strong>
19259                    <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>
19260                  </div>
19261                  <div class="glob-guidance-card">
19262                    <strong>Common include examples</strong>
19263                    <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>
19264                  </div>
19265                  <div class="glob-guidance-card">
19266                    <strong>Common exclude examples</strong>
19267                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19268                  </div>
19269                </div>
19270              </div>
19271
19272              <div class="section" style="margin-top:14px;">
19273                <div class="preset-inline-row git-inline-row">
19274                  <div class="toggle-card" style="margin:0;">
19275                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19276                    <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>
19277                    <div class="field" style="margin:0;">
19278                      <div class="input-group compact">
19279                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19280                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19281                      </div>
19282                      <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>
19283                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19284                    </div>
19285                  </div>
19286                  <div class="explainer-card prominent" style="margin:0;">
19287                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19288                    <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>
19289                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19290lcov --capture --directory . --output-file coverage/lcov.info
19291
19292# C / C++ — llvm-cov (LCOV)
19293llvm-profdata merge -sparse default.profraw -o default.profdata
19294llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19295
19296# C# — coverlet (Cobertura XML)
19297dotnet test --collect:"XPlat Code Coverage"
19298
19299# Python — pytest-cov (Cobertura XML)
19300pytest --cov --cov-report=xml
19301
19302# Python — coverage.py native JSON
19303coverage run -m pytest && coverage json   # writes coverage.json
19304
19305# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19306./gradlew jacocoTestReport</div>
19307                  </div>
19308                </div>
19309              </div>
19310
19311              <div class="wizard-actions">
19312                <div class="left"></div>
19313                <div class="right">
19314                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19315                    <span class="preview-gate-spinner" aria-hidden="true"></span>
19316                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
19317                    <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">
19318                      <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>
19319                    </button>
19320                  </div>
19321                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19322                </div>
19323              </div>
19324            </div>
19325
19326            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19327              <div class="default-path-modal">
19328                <h3 id="default-path-title">
19329                  <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>
19330                  Proceed with the default sample test?
19331                </h3>
19332                <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19333                <p>You haven&#39;t selected your own project yet.</p>
19334                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19335                <div class="default-path-actions">
19336                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19337                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19338                </div>
19339              </div>
19340            </div>
19341
19342            <div class="wizard-step" data-step="2">
19343              <div class="section">
19344                <div class="section-kicker">Step 2</div>
19345                <h2>Choose counting behavior</h2>
19346                <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>
19347<div class="subsection-bar">Primary line classification</div>
19348                <div class="preset-kv-row">
19349                  <div class="toggle-card mixed-line-card" style="margin:0;">
19350                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19351                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19352                    <select id="mixed_line_policy" name="mixed_line_policy">
19353                      <option value="code_only">Code only</option>
19354                      <option value="code_and_comment">Code and comment</option>
19355                      <option value="comment_only">Comment only</option>
19356                      <option value="separate_mixed_category">Separate mixed category</option>
19357                    </select>
19358                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19359                  </div>
19360                  <div class="explainer-card prominent" style="margin:0;">
19361                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19362                    <div class="explainer-body" id="mixed-policy-description"></div>
19363                    <div class="code-sample" id="mixed-policy-example"></div>
19364                  </div>
19365                </div>
19366              </div>
19367
19368              <div class="subsection-bar">Additional scan rules</div>
19369              <div class="scan-rules-grid">
19370                <div class="preset-inline-row">
19371                  <div class="toggle-card" style="margin:0;">
19372                    <div class="field-help-title">Generated files</div>
19373                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19374                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19375                  </div>
19376                  <div class="explainer-card prominent" style="margin:0;">
19377                    <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>
19378                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19379# Files matching codegen patterns are excluded:
19380#   *.generated.cs  *.pb.go  *.g.dart</div>
19381                  </div>
19382                </div>
19383                <div class="preset-inline-row">
19384                  <div class="toggle-card" style="margin:0;">
19385                    <div class="field-help-title">Minified files</div>
19386                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19387                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19388                  </div>
19389                  <div class="explainer-card prominent" style="margin:0;">
19390                    <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>
19391                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19392# Heuristic: very long lines + low whitespace ratio
19393#   jquery.min.js  bundle.min.css  → skipped</div>
19394                  </div>
19395                </div>
19396                <div class="preset-inline-row">
19397                  <div class="toggle-card" style="margin:0;">
19398                    <div class="field-help-title">Vendor directories</div>
19399                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19400                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19401                  </div>
19402                  <div class="explainer-card prominent" style="margin:0;">
19403                    <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>
19404                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19405# Directories named vendor/ node_modules/ third_party/
19406#   → entire subtree is excluded from totals</div>
19407                  </div>
19408                </div>
19409                <div class="preset-inline-row">
19410                  <div class="toggle-card" style="margin:0;">
19411                    <div class="field-help-title">Lockfiles and manifests</div>
19412                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19413                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19414                  </div>
19415                  <div class="explainer-card prominent" style="margin:0;">
19416                    <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>
19417                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
19418# Files like package-lock.json  Cargo.lock  yarn.lock
19419#   → skipped unless this is enabled</div>
19420                  </div>
19421                </div>
19422                <div class="preset-inline-row">
19423                  <div class="toggle-card" style="margin:0;">
19424                    <div class="field-help-title">Binary handling</div>
19425                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19426                    <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>
19427                  </div>
19428                  <div class="explainer-card prominent" style="margin:0;">
19429                    <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>
19430                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
19431# Detected via long lines + low whitespace heuristic
19432#   .png  .exe  .so  → skipped silently</div>
19433                  </div>
19434                </div>
19435                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19436                  <div class="toggle-card" style="margin:0;">
19437                    <div class="field-help-title">Python docstrings</div>
19438                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19439                    <label class="checkbox">
19440                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19441                      <span>Count as comment-style lines</span>
19442                    </label>
19443                  </div>
19444                  <div class="explainer-card prominent" style="margin:0;">
19445                    <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>
19446                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19447                  </div>
19448                </div>
19449              </div>
19450              <div class="subsection-bar">IEEE 1045-1992 counting</div>
19451              <div class="scan-rules-grid">
19452                <div class="preset-inline-row">
19453                  <div class="toggle-card" style="margin:0;">
19454                    <div class="field-help-title">Continuation lines</div>
19455                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19456                    <select name="continuation_line_policy" id="continuation_line_policy">
19457                      <option value="each_physical_line" selected>Each physical line (default)</option>
19458                      <option value="collapse_to_logical">Collapse to logical line</option>
19459                    </select>
19460                  </div>
19461                  <div class="explainer-card prominent" style="margin:0;">
19462                    <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>
19463                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19464    ((a) &gt; (b) ? (a) : (b))
19465# each_physical_line → 2 SLOC
19466# collapse_to_logical → 1 SLOC</div>
19467                  </div>
19468                </div>
19469                <div class="preset-inline-row">
19470                  <div class="toggle-card" style="margin:0;">
19471                    <div class="field-help-title">Block-comment blanks</div>
19472                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19473                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19474                      <option value="count_as_comment" selected>Count as comment (default)</option>
19475                      <option value="count_as_blank">Count as blank</option>
19476                    </select>
19477                  </div>
19478                  <div class="explainer-card prominent" style="margin:0;">
19479                    <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>
19480                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19481 * Summary line
19482 *              ← blank inside block comment
19483 * Detail line
19484 */
19485# count_as_comment → blank counts toward comments
19486# count_as_blank   → blank counts toward blanks</div>
19487                  </div>
19488                </div>
19489                <div class="preset-inline-row">
19490                  <div class="toggle-card" style="margin:0;">
19491                    <div class="field-help-title">Compiler directives</div>
19492                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19493                    <select name="count_compiler_directives" id="count_compiler_directives">
19494                      <option value="enabled" selected>Include in code SLOC (default)</option>
19495                      <option value="disabled">Exclude from code SLOC</option>
19496                    </select>
19497                  </div>
19498                  <div class="explainer-card prominent" style="margin:0;">
19499                    <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>
19500                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
19501#define BUF 256     ← compiler directive
19502int main() { … }   ← code
19503# enabled  → 3 code SLOC
19504# disabled → 1 code SLOC + 2 directive lines</div>
19505                  </div>
19506                </div>
19507              </div>
19508
19509              <div class="subsection-bar">Code Style Analysis</div>
19510              <div class="scan-rules-grid">
19511                <div class="preset-inline-row">
19512                  <div class="toggle-card" style="margin:0;">
19513                    <div class="field-help-title">Style analysis</div>
19514                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19515                    <select name="style_analysis_enabled" id="style_analysis_enabled">
19516                      <option value="enabled" selected>Enabled (default)</option>
19517                      <option value="disabled">Disabled — skip style scoring</option>
19518                    </select>
19519                  </div>
19520                  <div class="explainer-card prominent" style="margin:0;">
19521                    <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> — every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> — style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
19522                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
19523# style_analysis_enabled = false  (skip, faster scan)
19524# Disabling removes the Code Style section from the report.</div>
19525                  </div>
19526                </div>
19527                <div class="preset-inline-row">
19528                  <div class="toggle-card" style="margin:0;">
19529                    <div class="field-help-title">Column-width threshold</div>
19530                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19531                    <select name="style_col_threshold" id="style_col_threshold">
19532                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19533                      <option value="100">100 columns (Uber Go, Google Java)</option>
19534                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
19535                    </select>
19536                  </div>
19537                  <div class="explainer-card prominent" style="margin:0;">
19538                    <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>
19539                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
19540# style_col_threshold = 100 (Uber Go, Google Java)
19541# style_col_threshold = 120 (Uber Go max, Kotlin)
19542# Files where &lt;= 5% of lines exceed the limit
19543# are counted as "N-col compliant" in the report.</div>
19544                  </div>
19545                </div>
19546                <div class="preset-inline-row">
19547                  <div class="toggle-card" style="margin:0;">
19548                    <div class="field-help-title">Score alert threshold</div>
19549                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19550                    <select name="style_score_threshold" id="style_score_threshold">
19551                      <option value="0" selected>Off — no threshold (default)</option>
19552                      <option value="40">40% — flag poorly styled files</option>
19553                      <option value="50">50% — flag below-average files</option>
19554                      <option value="60">60% — flag below-good files</option>
19555                      <option value="70">70% — flag below-strong files</option>
19556                    </select>
19557                  </div>
19558                  <div class="explainer-card prominent" style="margin:0;">
19559                    <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>
19560                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
19561# style_score_threshold = 50  (flag files &lt; 50%)
19562# Low-scoring files get a red left-border in the
19563# per-file style breakdown table.</div>
19564                  </div>
19565                </div>
19566              </div>
19567
19568              <div class="always-tracked-tip">
19569                <div class="always-tracked-tip-icon">ℹ</div>
19570                <div class="always-tracked-tip-body">
19571                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
19572                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
19573                  <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>
19574                </div>
19575              </div>
19576
19577              <div class="subsection-bar">Advanced Metrics</div>
19578              <div class="scan-rules-grid">
19579                <div class="preset-inline-row">
19580                  <div class="toggle-card" style="margin:0;">
19581                    <div class="field-help-title">COCOMO mode</div>
19582                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19583                    <select name="cocomo_mode" id="cocomo_mode">
19584                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19585                      <option value="semi_detached">Semi-detached — mixed constraints</option>
19586                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
19587                    </select>
19588                  </div>
19589                  <div class="explainer-card prominent" style="margin:0;">
19590                    <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>
19591                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
19592# Semi-detached: Effort = 3.0 × KSLOC^1.12
19593# Embedded:     Effort = 3.6 × KSLOC^1.20
19594# All modes: Schedule = 2.5 × Effort^d</div>
19595                  </div>
19596                </div>
19597                <div class="preset-inline-row">
19598                  <div class="toggle-card" style="margin:0;">
19599                    <div class="field-help-title">Complexity alert</div>
19600                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19601                    <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;" />
19602                  </div>
19603                  <div class="explainer-card prominent" style="margin:0;">
19604                    <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>
19605                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19606# 50  = flag any file with &gt; 50 branch points
19607# 100 = flag any file with &gt; 100 branch points
19608# Files above the threshold are highlighted
19609# in the result page metric strip.</div>
19610                  </div>
19611                </div>
19612                <div class="preset-inline-row">
19613                  <div class="toggle-card" style="margin:0;">
19614                    <div class="field-help-title">Git hotspots</div>
19615                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19616                    <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;" />
19617                  </div>
19618                  <div class="explainer-card prominent" style="margin:0;">
19619                    <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>
19620                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
19621# 30  = last month of activity
19622# 365 = last year
19623# 0   = disable the hotspots table
19624# Adds Commits + Last-changed columns to CSV.</div>
19625                  </div>
19626                </div>
19627                <div class="preset-inline-row">
19628                  <div class="toggle-card" style="margin:0;">
19629                    <div class="field-help-title">Duplicate handling</div>
19630                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19631                    <select name="exclude_duplicates" id="exclude_duplicates">
19632                      <option value="disabled" selected>Detect and report only (default)</option>
19633                      <option value="enabled">Detect and exclude from SLOC totals</option>
19634                    </select>
19635                  </div>
19636                  <div class="explainer-card prominent" style="margin:0;">
19637                    <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>
19638                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19639# detect only   → all 3 counted in SLOC
19640# exclude dupes → 1 counted, 2 excluded
19641# Duplicate groups chip always shows the count.</div>
19642                  </div>
19643                </div>
19644                <div class="always-tracked-tip" style="margin:8px 0 0;">
19645                  <div class="always-tracked-tip-icon">ℹ</div>
19646                  <div class="always-tracked-tip-body">
19647                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
19648                    <div class="always-tracked-metrics-row">
19649                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19650                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
19651                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
19652                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
19653                    </div>
19654                    <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>
19655                  </div>
19656                </div>
19657              </div>
19658
19659              <div class="wizard-actions">
19660                <div class="left">
19661                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19662                </div>
19663                <div class="right">
19664                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19665                </div>
19666              </div>
19667            </div>
19668
19669            <div class="wizard-step" data-step="3">
19670              <div class="section">
19671                <div class="section-kicker">Step 3</div>
19672                <h2>Output and report identity</h2>
19673                <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>
19674                <div class="preset-kv-row">
19675                  <div class="toggle-card" style="margin:0;">
19676                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19677                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19678                    <select id="scan_preset">
19679                      <option value="balanced">Balanced local scan</option>
19680                      <option value="code_focused">Code focused</option>
19681                      <option value="comment_audit">Comment audit</option>
19682                      <option value="deep_review">Deep review</option>
19683                    </select>
19684                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19685                  </div>
19686                  <div class="explainer-card">
19687                    <div class="field-help-title">Selected scan preset</div>
19688                    <div class="explainer-body" id="scan-preset-description"></div>
19689                    <div class="preset-summary-row" id="scan-preset-summary"></div>
19690                    <div class="code-sample" id="scan-preset-example"></div>
19691                    <div class="preset-note" id="scan-preset-note"></div>
19692                  </div>
19693                </div>
19694                <hr class="step3-separator" />
19695                <div class="preset-kv-row">
19696                  <div class="toggle-card" style="margin:0;">
19697                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19698                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19699                    <select id="artifact_preset">
19700                      <option value="review">Review bundle</option>
19701                      <option value="full">Full bundle</option>
19702                      <option value="html_only">HTML only</option>
19703                      <option value="machine">Machine bundle</option>
19704                    </select>
19705                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19706                  </div>
19707                  <div class="explainer-card">
19708                    <div class="field-help-title">Selected artifact preset</div>
19709                    <div class="explainer-body" id="artifact-preset-description"></div>
19710                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
19711                    <div class="code-sample" id="artifact-preset-example"></div>
19712                  </div>
19713                </div>
19714              </div>
19715
19716              <div class="section section-spacer-top">
19717                <div class="output-field-row">
19718                  <div class="field">
19719                    <label for="output_dir">Output directory</label>
19720                    {% if server_mode %}
19721                    <div class="input-group compact">
19722                      <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);" />
19723                    </div>
19724                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19725                    {% else %}
19726                    <div class="input-group compact">
19727                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19728                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19729                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
19730                    </div>
19731                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19732                    {% endif %}
19733                  </div>
19734                  <div class="output-field-aside">
19735                    <strong>Where reports land</strong>
19736                    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.
19737                  </div>
19738                </div>
19739              </div>
19740
19741              <div class="section section-spacer-top">
19742                <div class="output-field-row">
19743                  <div class="field">
19744                    <label for="report_title">Report title</label>
19745                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19746                    <div class="hint">Appears in HTML and PDF output headers.</div>
19747                  </div>
19748                  <div class="output-field-aside">
19749                    <strong>Shown in exported artifacts</strong>
19750                    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.
19751                  </div>
19752                </div>
19753              </div>
19754
19755              <div class="section section-spacer-top">
19756                <div class="output-field-row">
19757                  <div class="field">
19758                    <label for="report_header_footer">Report header / footer</label>
19759                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19760                    <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>
19761                  </div>
19762                  <div class="output-field-aside">
19763                    <strong>Page-level identification</strong>
19764                    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.
19765                  </div>
19766                </div>
19767              </div>
19768
19769              <div class="wizard-actions">
19770                <div class="left">
19771                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19772                </div>
19773                <div class="right">
19774                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19775                </div>
19776              </div>
19777            </div>
19778
19779            <div class="wizard-step" data-step="4">
19780              <div class="section">
19781                <div class="section-kicker">Step 4</div>
19782                <h2>Review selections and run</h2>
19783                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19784                <div class="review-grid">
19785                  <div class="review-card highlight">
19786                    <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>
19787                    <ul id="review-scan-summary"></ul>
19788                  </div>
19789                  <div class="review-card highlight">
19790                    <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>
19791                    <ul id="review-count-summary"></ul>
19792                  </div>
19793                  <div class="review-card">
19794                    <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>
19795                    <ul id="review-artifact-summary"></ul>
19796                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19797                  </div>
19798                  <div class="review-card">
19799                    <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>
19800                    <ul id="review-preview-summary"></ul>
19801                  </div>
19802                </div>
19803              </div>
19804
19805              <div class="wizard-actions">
19806                <div class="left">
19807                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19808                </div>
19809                <div class="right">
19810                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
19811                </div>
19812              </div>
19813            </div>
19814            {% if server_mode %}
19815            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19816            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19817            {% endif %}
19818          </form>
19819        </div>
19820      </section>
19821    </div>
19822  </div>
19823
19824  <script nonce="{{ csp_nonce }}">
19825    (function () {
19826      function startScanPhase() {
19827        var phaseEl = document.getElementById("scan-phase");
19828        if (!phaseEl) return;
19829        var phases = [
19830          "Discovering files...",
19831          "Decoding file encodings...",
19832          "Detecting languages...",
19833          "Analyzing source lines...",
19834          "Applying counting policies...",
19835          "Aggregating results...",
19836          "Rendering report..."
19837        ];
19838        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19839        var i = 0;
19840        function next() {
19841          phaseEl.style.opacity = "0";
19842          setTimeout(function () {
19843            phaseEl.textContent = phases[i];
19844            phaseEl.style.opacity = "0.85";
19845            var delay = durations[i] || 1800;
19846            i++;
19847            if (i < phases.length) { setTimeout(next, delay); }
19848          }, 200);
19849        }
19850        next();
19851      }
19852
19853      var form = document.getElementById("analyze-form");
19854      var loading = document.getElementById("loading");
19855      var submitButton = document.getElementById("submit-button");
19856      var pathInput = document.getElementById("path");
19857      var GIT_MODE = !!(pathInput && pathInput.readOnly);
19858      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19859      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19860      var outputDirInput = document.getElementById("output_dir");
19861      var reportTitleInput = document.getElementById("report_title");
19862      var previewPanel = document.getElementById("preview-panel");
19863      var refreshButton = document.getElementById("refresh-preview");
19864      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19865      var useSamplePath = document.getElementById("use-sample-path");
19866      var useDefaultOutput = document.getElementById("use-default-output");
19867      var browsePath = document.getElementById("browse-path");
19868      var browseOutputDir = document.getElementById("browse-output-dir");
19869      var browseCoverage = document.getElementById("browse-coverage");
19870      var coverageInput = document.getElementById("coverage_file");
19871      var covScanStatus = document.getElementById("cov-scan-status");
19872      var coverageSuggestTimer = null;
19873      var covAutoFilled = false;
19874      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19875
19876      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19877      (function() {
19878        var ids = ["path", "output_dir"];
19879        ids.forEach(function(id) {
19880          var el = document.getElementById(id);
19881          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19882        });
19883      }());
19884      function fmtBytes(b) {
19885        b = Number(b) || 0;
19886        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19887        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19888        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
19889        return b + ' B';
19890      }
19891      var themeToggle = document.getElementById("theme-toggle");
19892
19893      function showBannerToast(msg, isError, opts) {
19894        opts = opts || {};
19895        var t = document.createElement('div');
19896        t.className = isError ? 'toast-error' : 'toast-success';
19897        var topPos = opts.top ? '80px' : null;
19898        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19899          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19900          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19901          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19902        if (opts.icon) {
19903          var inner = document.createElement('span');
19904          inner.innerHTML = opts.icon + ' ';
19905          t.appendChild(inner);
19906        }
19907        t.appendChild(document.createTextNode(msg));
19908        document.body.appendChild(t);
19909        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19910      }
19911      var mixedLinePolicy = document.getElementById("mixed_line_policy");
19912      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19913      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19914      var scanPreset = document.getElementById("scan_preset");
19915      var artifactPreset = document.getElementById("artifact_preset");
19916      var includeGlobsInput = document.getElementById("include_globs");
19917      var excludeGlobsInput = document.getElementById("exclude_globs");
19918
19919      // Include globs scope badge — updates reactively as the user types.
19920      (function() {
19921        var badge = document.getElementById("include-scope-badge");
19922        if (!badge || !includeGlobsInput) return;
19923        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> ';
19924        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> ';
19925        function update() {
19926          var val = includeGlobsInput.value.trim();
19927          if (!val) {
19928            badge.className = "include-scope-badge scope-all";
19929            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19930          } else {
19931            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19932            badge.className = "include-scope-badge scope-narrow";
19933            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19934          }
19935        }
19936        includeGlobsInput.addEventListener("input", update);
19937        update();
19938      }());
19939
19940      // Quick-exclude chips — append pattern to exclude_globs textarea.
19941      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19942        chip.addEventListener("click", function() {
19943          var pattern = chip.getAttribute("data-pattern") || "";
19944          if (!pattern || !excludeGlobsInput) return;
19945          var current = excludeGlobsInput.value.trim();
19946          // For the "skip all" chip, replace any existing dep patterns cleanly.
19947          var patterns = pattern.split("\n");
19948          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19949          var added = false;
19950          patterns.forEach(function(p) {
19951            p = p.trim();
19952            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19953          });
19954          if (added) {
19955            excludeGlobsInput.value = lines.join("\n");
19956            excludeGlobsInput.dispatchEvent(new Event("input"));
19957          }
19958          chip.classList.add("active");
19959        });
19960      });
19961
19962      var liveReportTitle = document.getElementById("live-report-title");
19963      var navProjectPill = document.getElementById("nav-project-pill");
19964      var navProjectTitle = document.getElementById("nav-project-title");
19965      var reportTitlePreview = null;
19966      var wizardProgressFill = document.getElementById("wizard-progress-fill");
19967      var wizardProgressValue = document.getElementById("wizard-progress-value");
19968      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19969      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19970      var reportTitleTouched = false;
19971      var currentStep = 1;
19972      var previewTimer = null;
19973      var _previewGen = 0;
19974      // True while the scope preview (local) / project upload (server mode) is in
19975      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19976      // user can't advance past a project whose scope/upload isn't ready yet.
19977      var previewLoading = false;
19978      // Set when the current preview reports multiple independent git repos under
19979      // the selected root. Advancing past step 1 is blocked until the user ticks
19980      // the acknowledgement checkbox (or re-selects a single repository).
19981      var multiRepoBlocked = false;
19982      function step1ForwardBlocked() {
19983        return previewLoading || multiRepoBlocked;
19984      }
19985      function refreshStep1Gate() {
19986        var nextBtn = document.getElementById("step1-next");
19987        if (nextBtn) {
19988          var blocked = step1ForwardBlocked();
19989          nextBtn.classList.toggle("is-blocked", blocked);
19990          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19991        }
19992      }
19993      function setPreviewLoading(loading) {
19994        previewLoading = !!loading;
19995        var gate = document.getElementById("preview-gate-status");
19996        refreshStep1Gate();
19997        if (gate) {
19998          var txt = gate.querySelector(".preview-gate-text");
19999          if (txt) txt.textContent = SERVER_MODE
20000            ? "Uploading & scanning project…"
20001            : "Scanning project scope…";
20002          gate.style.display = previewLoading ? "flex" : "none";
20003        }
20004      }
20005      // Info button on the gate: scroll up to the live scope preview so the user
20006      // can see exactly what is being scanned (elapsed time + rotating status).
20007      var previewGateInfo = document.getElementById("preview-gate-info");
20008      if (previewGateInfo) {
20009        previewGateInfo.addEventListener("click", function () {
20010          var target = document.getElementById("preview-panel");
20011          if (!target) return;
20012          target.scrollIntoView({ behavior: "smooth", block: "center" });
20013          target.classList.add("preview-panel-flash");
20014          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
20015        });
20016      }
20017      var quickScanBtn = document.getElementById("quick-scan-btn");
20018
20019      function dismissAnalysisModal() {
20020        if (loading) loading.classList.remove("active");
20021        document.body.classList.remove("modal-open");
20022        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20023          var el = document.getElementById(id);
20024          if (el) el.classList.add("hidden");
20025        });
20026        var cancelBtn = document.getElementById("lc-cancel-btn");
20027        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
20028        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
20029        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
20030        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
20031        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");}
20032        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
20033        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
20034        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20035        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20036        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20037        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20038      }
20039
20040      var lcDismissBtn = document.getElementById("lc-dismiss");
20041      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
20042
20043      // When the browser restores this page from bfcache (Back button after navigating to results),
20044      // the loading overlay would still be showing its active state. Dismiss it immediately.
20045      window.addEventListener("pageshow", function(e) {
20046        if (e.persisted) { dismissAnalysisModal(); }
20047      });
20048
20049      function startAsyncAnalysis(formData) {
20050        var gitRepo = (formData.get("git_repo") || "").toString();
20051        var gitRef  = (formData.get("git_ref")  || "").toString();
20052        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
20053        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
20054
20055        var pathEl = document.getElementById("lc-path-text");
20056        if (pathEl) pathEl.textContent = displayPath;
20057
20058        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20059          var el = document.getElementById(id);
20060          if (el) el.classList.add("hidden");
20061        });
20062        var cancelBtn = document.getElementById("lc-cancel-btn");
20063        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
20064        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
20065        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20066        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20067        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
20068        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
20069        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
20070        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");}
20071        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
20072
20073        if (loading) loading.classList.add("active");
20074        document.body.classList.add("modal-open");
20075
20076        var startTime = Date.now();
20077        var elapsedTimer = setInterval(function() {
20078          var s = Math.floor((Date.now() - startTime) / 1000);
20079          var el = document.getElementById("lc-elapsed");
20080          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
20081        }, 1000);
20082
20083        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
20084
20085        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();}
20086
20087        var PHASE_DESC = {
20088          'Starting': 'Initializing language analyzers and loading configuration\u2026',
20089          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
20090          'Running': 'Running the lexical state machine across all discovered source files\u2026',
20091          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
20092          'Done': 'Analysis complete \u2014 loading your results\u2026',
20093          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
20094        };
20095        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
20096        function lcSetPhase(txt) {
20097          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
20098          var desc = document.getElementById("lc-stage-desc");
20099          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
20100          var step = PHASE_STEP[txt] || 1;
20101          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");}
20102        }
20103
20104        function lcShowCancelled() {
20105          clearInterval(elapsedTimer);
20106          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
20107          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
20108          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
20109          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
20110          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
20111          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
20112          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
20113          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
20114          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20115          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20116        }
20117
20118        var lcCancelBtn = document.getElementById("lc-cancel-btn");
20119        if (lcCancelBtn) {
20120          lcCancelBtn.onclick = function() {
20121            if (!activeWaitId) { dismissAnalysisModal(); return; }
20122            lcCancelBtn.disabled = true;
20123            lcCancelBtn.textContent = "Cancelling\u2026";
20124            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
20125              .then(function() { lcShowCancelled(); })
20126              .catch(function() { lcShowCancelled(); });
20127          };
20128        }
20129
20130        function lcShowError(msg) {
20131          clearInterval(elapsedTimer);
20132          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
20133          lcSetPhase("Failed");
20134          var msgEl = document.getElementById("lc-err-msg");
20135          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
20136          var errEl = document.getElementById("lc-err");
20137          var actEl = document.getElementById("lc-actions");
20138          if (errEl) errEl.classList.remove("hidden");
20139          if (actEl) actEl.classList.remove("hidden");
20140          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20141          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20142        }
20143
20144        function lcPoll(waitId) {
20145          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
20146            .then(function(r) {
20147              if (!r.ok) throw new Error("HTTP " + r.status);
20148              return r.json();
20149            })
20150            .then(function(data) {
20151              pollRetries = 0;
20152              if (data.state === "complete") {
20153                clearInterval(elapsedTimer);
20154                lcSetPhase("Done");
20155                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
20156              } else if (data.state === "failed") {
20157                lcShowError(data.message);
20158              } else if (data.state === "cancelled") {
20159                lcShowCancelled();
20160              } else {
20161                var s = Math.floor((Date.now() - startTime) / 1000);
20162                if (s > 90 && !warnShown) {
20163                  warnShown = true;
20164                  var w = document.getElementById("lc-warn");
20165                  if (w) w.classList.remove("hidden");
20166                }
20167                lcSetPhase(data.phase || "Running");
20168                var fd = data.files_done || 0, ft = data.files_total || 0;
20169                if (ft > 0) {
20170                  var card = document.getElementById("lc-files-card");
20171                  if (card) card.classList.remove("hidden");
20172                  var el = document.getElementById("lc-files");
20173                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
20174                  var now = Date.now();
20175                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
20176                  if (fdelta > 0 && tdelta > 0.4) {
20177                    var fps = Math.round(fdelta / tdelta);
20178                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
20179                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
20180                  }
20181                  lastFd = fd; lastFdTime = now;
20182                }
20183                setTimeout(function() { lcPoll(waitId); }, 1500);
20184              }
20185            })
20186            .catch(function() {
20187              pollRetries++;
20188              if (pollRetries >= 5) {
20189                lcShowError("Lost connection to server. Reload to check status.");
20190              } else {
20191                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
20192              }
20193            });
20194        }
20195
20196        var params = new URLSearchParams(formData);
20197        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
20198          .then(function(r) {
20199            var waitId = r.headers.get("x-wait-id");
20200            if (!waitId) { window.location.href = "/scan"; return; }
20201            activeWaitId = waitId;
20202            setTimeout(function() { lcPoll(waitId); }, 1500);
20203          })
20204          .catch(function(err) {
20205            lcShowError("Could not reach server: " + (err.message || err));
20206          });
20207      }
20208
20209      if (quickScanBtn) {
20210        quickScanBtn.addEventListener("click", function () {
20211          var pathVal = pathInput ? pathInput.value.trim() : "";
20212          if (!pathVal) {
20213            alert("Please enter or browse to a project path first.");
20214            return;
20215          }
20216          quickScanBtn.disabled = true;
20217          quickScanBtn.textContent = "Scanning...";
20218          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
20219          startAsyncAnalysis(new FormData(form));
20220        });
20221      }
20222
20223      var mixedPolicyInfo = {
20224        code_only: {
20225          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.",
20226          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'
20227        },
20228        code_and_comment: {
20229          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.",
20230          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'
20231        },
20232        comment_only: {
20233          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.",
20234          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'
20235        },
20236        separate_mixed_category: {
20237          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.",
20238          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'
20239        }
20240      };
20241
20242      var scanPresetInfo = {
20243        balanced: {
20244          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.",
20245          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20246          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20247          note: "Best when you want a stable local overview before making deeper adjustments.",
20248          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20249        },
20250        code_focused: {
20251          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20252          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20253          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20254          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20255          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20256        },
20257        comment_audit: {
20258          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20259          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20260          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20261          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20262          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20263        },
20264        deep_review: {
20265          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20266          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20267          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20268          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20269          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20270        }
20271      };
20272
20273      var artifactPresetInfo = {
20274        review: {
20275          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20276          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20277          example: "Ideal for a quick local review before sharing results."
20278        },
20279        full: {
20280          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20281          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20282          example: "Use when producing a deliverable or storing a snapshot for future comparison."
20283        },
20284        html_only: {
20285          description: "Standalone HTML report only. No PDF generation, no data files.",
20286          chips: ["HTML only"],
20287          example: "Fastest option when you only need to open the report in a browser."
20288        },
20289        machine: {
20290          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20291          chips: ["JSON", "CSV", "no HTML", "no PDF"],
20292          example: "Use in CI to capture metrics without generating visual reports."
20293        }
20294      };
20295
20296      function applyArtifactPreset() {
20297        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20298        if (!info) return;
20299        var descEl = document.getElementById("artifact-preset-description");
20300        var exampleEl = document.getElementById("artifact-preset-example");
20301        if (descEl) descEl.textContent = info.description;
20302        if (exampleEl) exampleEl.textContent = info.example;
20303        renderPresetChips("artifact-preset-summary", info.chips);
20304      }
20305
20306      function applyTheme(theme) {
20307        if (theme === "dark") document.body.classList.add("dark-theme");
20308        else document.body.classList.remove("dark-theme");
20309      }
20310
20311      function loadSavedTheme() {
20312        var saved = null;
20313        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20314        applyTheme(saved === "dark" ? "dark" : "light");
20315      }
20316
20317      function updateScrollProgress() {
20318        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20319        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20320        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
20321        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20322        var step = Math.min(Math.max(currentStep, 1), 4);
20323        var base = stepBase[step];
20324        var end  = stepEnd[step];
20325
20326        var scrollFrac = 0;
20327        var activePanel = document.querySelector(".wizard-step.active");
20328        if (activePanel) {
20329          var scrollTop = window.scrollY || window.pageYOffset || 0;
20330          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20331          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20332          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20333          var scrolled = scrollTop + viewH - panelTop;
20334          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20335        }
20336
20337        var percent = Math.round(base + (end - base) * scrollFrac);
20338        percent = Math.min(end, Math.max(base, percent));
20339        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20340        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20341      }
20342
20343      function updateWizardProgress() {
20344        updateScrollProgress();
20345      }
20346
20347      var stepDescriptions = [
20348        "Choose a project folder, apply scope filters, and preview which files will be counted.",
20349        "Configure how mixed code-plus-comment lines and docstrings are classified.",
20350        "Pick your output formats, scan preset, and where reports are saved.",
20351        "Review all settings and launch the analysis."
20352      ];
20353
20354      function updateStepNav(step) {
20355        var infoLabel = document.getElementById("step-nav-info-label");
20356        var infoDesc  = document.getElementById("step-nav-info-desc");
20357        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20358        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
20359      }
20360
20361      function updateSidebarSummary() {
20362        var sumPath    = document.getElementById("sum-path");
20363        var sumPreset  = document.getElementById("sum-preset");
20364        var sumOutput  = document.getElementById("sum-output");
20365        var sidebarSummary = document.getElementById("sidebar-summary");
20366        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20367        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
20368        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20369        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
20370        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20371        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20372        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20373      }
20374
20375      function setStep(step, pushHistory) {
20376        currentStep = step;
20377        stepPanels.forEach(function (panel) {
20378          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20379        });
20380        stepButtons.forEach(function (button) {
20381          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20382        });
20383        var layoutEl = document.querySelector(".layout");
20384        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20385        updateWizardProgress();
20386        updateStepNav(step);
20387        stepButtons.forEach(function(btn) {
20388          var t = Number(btn.getAttribute("data-step-target"));
20389          btn.classList.toggle("done", t < step);
20390        });
20391        updateSidebarSummary();
20392
20393        if (pushHistory !== false) {
20394          try {
20395            history.pushState({ wizardStep: step }, "", "#step" + step);
20396          } catch (e) {}
20397        }
20398
20399        window.scrollTo({ top: 0, behavior: "instant" });
20400      }
20401
20402      window.addEventListener("popstate", function (e) {
20403        if (e.state && e.state.wizardStep) {
20404          setStep(e.state.wizardStep, false);
20405        } else {
20406          var hashMatch = location.hash.match(/^#step([1-4])$/);
20407          if (hashMatch) setStep(Number(hashMatch[1]), false);
20408        }
20409      });
20410
20411      function inferTitleFromPath(value) {
20412        if (!value) return "project";
20413        var cleaned = value.replace(/[\/\\]+$/, "");
20414        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20415        return parts.length ? parts[parts.length - 1] : value;
20416      }
20417
20418      function updateReportTitleFromPath() {
20419        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20420        if (!reportTitleTouched) {
20421          reportTitleInput.value = inferred;
20422        }
20423        var title = reportTitleInput.value || inferred;
20424        if (liveReportTitle) liveReportTitle.textContent = title;
20425        if (reportTitlePreview) reportTitlePreview.textContent = title;
20426        document.title = "OxideSLOC | " + title;
20427
20428        var projectPath = (pathInput.value || "").trim();
20429        if (navProjectPill && navProjectTitle) {
20430          if (projectPath.length > 0) {
20431            navProjectTitle.textContent = inferred;
20432            navProjectPill.classList.add("visible");
20433          } else {
20434            navProjectTitle.textContent = "";
20435            navProjectPill.classList.remove("visible");
20436          }
20437        }
20438      }
20439
20440      function updateMixedPolicyUI() {
20441        var key = mixedLinePolicy.value || "code_only";
20442        var info = mixedPolicyInfo[key];
20443        document.getElementById("mixed-policy-description").textContent = info.description;
20444        document.getElementById("mixed-policy-example").textContent = info.example;
20445      }
20446
20447      function updatePythonDocstringUI() {
20448        var checked = !!pythonDocstrings.checked;
20449        document.getElementById("python-docstring-example").textContent = checked
20450          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
20451          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
20452        document.getElementById("python-docstring-live-help").textContent = checked
20453          ? "Enabled: docstrings contribute to comment-style totals."
20454          : "Disabled: docstrings are not counted as comment content.";
20455      }
20456
20457      function renderPresetChips(targetId, chips) {
20458        var target = document.getElementById(targetId);
20459        if (!target) return;
20460        target.innerHTML = (chips || []).map(function (chip) {
20461          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20462        }).join('');
20463      }
20464
20465      function updatePresetDescriptions() {
20466        var scanInfo = scanPresetInfo[scanPreset.value];
20467        if (!scanInfo) return;
20468        document.getElementById("scan-preset-description").textContent = scanInfo.description;
20469        document.getElementById("scan-preset-example").textContent = scanInfo.example;
20470        document.getElementById("scan-preset-note").textContent = scanInfo.note;
20471        renderPresetChips("scan-preset-summary", scanInfo.chips);
20472      }
20473
20474      function applyScanPreset() {
20475        var info = scanPresetInfo[scanPreset.value];
20476        if (!info || !info.apply) return;
20477        mixedLinePolicy.value = info.apply.mixed;
20478        pythonDocstrings.checked = !!info.apply.docstrings;
20479        document.getElementById("generated_file_detection").value = info.apply.generated;
20480        document.getElementById("minified_file_detection").value = info.apply.minified;
20481        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20482        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20483        document.getElementById("binary_file_behavior").value = info.apply.binary;
20484        updateMixedPolicyUI();
20485        updatePythonDocstringUI();
20486      }
20487
20488      function updateReview() {
20489        var scanSummary = document.getElementById("review-scan-summary");
20490        var countSummary = document.getElementById("review-count-summary");
20491        var artifactSummary = document.getElementById("review-artifact-summary");
20492        var outputSummary = document.getElementById("review-output-summary");
20493        var previewSummary = document.getElementById("review-preview-summary");
20494        var readinessSummary = document.getElementById("review-readiness-summary");
20495        var includeText = document.getElementById("include_globs").value.trim();
20496        var excludeText = document.getElementById("exclude_globs").value.trim();
20497        var sidePathPreview = document.getElementById("side-path-preview");
20498        var sideOutputPreview = document.getElementById("side-output-preview");
20499        var sideTitlePreview = document.getElementById("side-title-preview");
20500
20501        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20502        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20503        if (sideTitlePreview) {
20504          var rt = document.getElementById("report_title");
20505          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20506        }
20507
20508        scanSummary.innerHTML = ""
20509          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20510          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20511          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20512
20513        countSummary.innerHTML = ""
20514          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20515          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20516          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20517          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20518          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20519          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20520          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20521          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20522
20523        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20524
20525        outputSummary.innerHTML = ""
20526          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20527          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20528
20529        if (previewSummary) {
20530          if (GIT_MODE) {
20531            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>';
20532          } else {
20533          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20534          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20535          var statMap = {};
20536          statButtons.forEach(function (button) {
20537            var valueNode = button.querySelector('.scope-stat-value');
20538            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20539          });
20540          previewSummary.innerHTML = ''
20541            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20542            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20543            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20544            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20545            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20546            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20547
20548          if (readinessSummary) {
20549            readinessSummary.innerHTML = ''
20550              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20551              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20552              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20553          }
20554          } // end else (non-GIT_MODE)
20555        }
20556      }
20557
20558      function escapeHtml(value) {
20559        return String(value)
20560          .replace(/&/g, "&amp;")
20561          .replace(/</g, "&lt;")
20562          .replace(/>/g, "&gt;")
20563          .replace(/"/g, "&quot;")
20564          .replace(/'/g, "&#39;");
20565      }
20566
20567      function isPythonVisible() {
20568        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20569      }
20570
20571      function syncPythonVisibility() {
20572        var html = previewPanel.textContent || "";
20573        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20574        pythonWraps.forEach(function (node) {
20575          node.classList.toggle("hidden", !hasPython);
20576        });
20577      }
20578
20579      function attachPreviewInteractions() {
20580        // Multiple-repository caution banner: gate step 1 until acknowledged, and
20581        // let each listed repo be picked as the scan root with one click.
20582        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20583        if (multiRepoBanner) {
20584          multiRepoBlocked = true;
20585          refreshStep1Gate();
20586          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20587          if (ackBox) {
20588            ackBox.addEventListener("change", function () {
20589              multiRepoBlocked = !ackBox.checked;
20590              refreshStep1Gate();
20591            });
20592          }
20593          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20594          repoButtons.forEach(function (btn) {
20595            btn.addEventListener("click", function () {
20596              var repoPath = btn.getAttribute("data-repo-path") || "";
20597              if (!repoPath || !pathInput) return;
20598              pathInput.value = repoPath;
20599              scrollInputToEnd(pathInput);
20600              updateReportTitleFromPath();
20601              autoSetOutputDir(repoPath);
20602              fetchProjectHistory(repoPath);
20603              loadPreview();
20604              updateReview();
20605            });
20606          });
20607        }
20608        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20609        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20610        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20611        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20612        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20613        var searchInput = previewPanel.querySelector("#explorer-search");
20614        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20615        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20616        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20617        var activeFilter = "all";
20618        var activeLanguage = "";
20619        var searchTerm = "";
20620        var currentSortKey = null;
20621        var currentSortOrder = "asc";
20622        var childRows = {};
20623
20624        rows.forEach(function (row) {
20625          var parentId = row.getAttribute("data-parent-id") || "";
20626          var rowId = row.getAttribute("data-row-id") || "";
20627          if (!childRows[parentId]) childRows[parentId] = [];
20628          childRows[parentId].push(rowId);
20629        });
20630
20631        function rowById(id) {
20632          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20633        }
20634
20635        function hasCollapsedAncestor(row) {
20636          var parentId = row.getAttribute("data-parent-id");
20637          while (parentId) {
20638            var parent = rowById(parentId);
20639            if (!parent) break;
20640            if (parent.getAttribute("data-expanded") === "false") return true;
20641            parentId = parent.getAttribute("data-parent-id");
20642          }
20643          return false;
20644        }
20645
20646        function updateToggleGlyph(row) {
20647          var toggle = row.querySelector(".tree-toggle");
20648          if (!toggle) return;
20649          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20650        }
20651
20652        function rowSortValue(row, key) {
20653          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20654        }
20655
20656        function updateSortButtons() {
20657          sortButtons.forEach(function (button) {
20658            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20659            var indicator = button.querySelector(".tree-sort-indicator");
20660            button.classList.toggle("active", isActive);
20661            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20662            if (indicator) {
20663              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20664            }
20665          });
20666        }
20667
20668        function sortSiblingRows() {
20669          if (!treeContainer) {
20670            updateSortButtons();
20671            return;
20672          }
20673
20674          var rowMap = {};
20675          var childrenMap = {};
20676          rows.forEach(function (row) {
20677            var rowId = row.getAttribute("data-row-id");
20678            var parentId = row.getAttribute("data-parent-id") || "";
20679            rowMap[rowId] = row;
20680            if (!childrenMap[parentId]) childrenMap[parentId] = [];
20681            childrenMap[parentId].push(rowId);
20682          });
20683
20684          Object.keys(childrenMap).forEach(function (parentId) {
20685            if (!parentId) return;
20686            childrenMap[parentId].sort(function (a, b) {
20687              var rowA = rowMap[a];
20688              var rowB = rowMap[b];
20689              if (!currentSortKey) {
20690                return Number(a) - Number(b);
20691              }
20692              var valueA = rowSortValue(rowA, currentSortKey);
20693              var valueB = rowSortValue(rowB, currentSortKey);
20694              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20695              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20696              var fallbackA = rowSortValue(rowA, "name");
20697              var fallbackB = rowSortValue(rowB, "name");
20698              if (fallbackA < fallbackB) return -1;
20699              if (fallbackA > fallbackB) return 1;
20700              return Number(a) - Number(b);
20701            });
20702          });
20703
20704          var orderedIds = [];
20705          function pushChildren(parentId) {
20706            (childrenMap[parentId] || []).forEach(function (childId) {
20707              orderedIds.push(childId);
20708              pushChildren(childId);
20709            });
20710          }
20711
20712          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20713            orderedIds.push(topId);
20714            pushChildren(topId);
20715          });
20716
20717          orderedIds.forEach(function (id) {
20718            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20719          });
20720          updateSortButtons();
20721        }
20722
20723        function updateLanguageButtons() {
20724          languageButtons.forEach(function (button) {
20725            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20726            var isActive = languageValue === activeLanguage;
20727            button.classList.toggle("active", isActive);
20728          });
20729        }
20730
20731        function rowSelfMatches(row) {
20732          var kind = row.getAttribute("data-kind");
20733          var status = row.getAttribute("data-status");
20734          var language = (row.getAttribute("data-language") || "").toLowerCase();
20735          var name = row.getAttribute("data-name-lower") || "";
20736          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20737          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20738          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20739          var passesLanguage = !activeLanguage || language === activeLanguage;
20740          return passesFilter && passesSearch && passesLanguage;
20741        }
20742
20743        function hasMatchingDescendant(rowId) {
20744          return (childRows[rowId] || []).some(function (childId) {
20745            var childRow = rowById(childId);
20746            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20747          });
20748        }
20749
20750        function rowMatches(row) {
20751          if (rowSelfMatches(row)) return true;
20752          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20753        }
20754
20755        function resetViewState() {
20756          activeFilter = "all";
20757          activeLanguage = "";
20758          searchTerm = "";
20759          currentSortKey = null;
20760          currentSortOrder = "asc";
20761          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20762          if (searchInput) searchInput.value = "";
20763          if (filterSelect) filterSelect.value = "all";
20764          updateLanguageButtons();
20765        }
20766
20767        function applyVisibility() {
20768          rows.forEach(function (row) {
20769            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20770            row.classList.toggle("hidden-by-filter", !visible);
20771            row.style.display = visible ? "grid" : "none";
20772          });
20773          buttons.forEach(function (button) {
20774            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20775          });
20776          if (filterSelect) filterSelect.value = activeFilter;
20777        }
20778
20779        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20780        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20781        var originalStats = {};
20782        buttons.forEach(function (btn) {
20783          var f = btn.getAttribute('data-filter');
20784          var v = btn.querySelector('.scope-stat-value');
20785          if (f && v) originalStats[f] = v.textContent;
20786        });
20787
20788        function applySubmoduleStats(statsJson) {
20789          try {
20790            var s = JSON.parse(statsJson);
20791            buttons.forEach(function (btn) {
20792              var f = btn.getAttribute('data-filter');
20793              var v = btn.querySelector('.scope-stat-value');
20794              if (!v) return;
20795              if (f === 'dir') v.textContent = s.dirs;
20796              else if (f === 'file') v.textContent = s.files;
20797              else if (f === 'supported') v.textContent = s.supported;
20798              else if (f === 'skipped') v.textContent = s.skipped;
20799              else if (f === 'unsupported') v.textContent = s.unsupported;
20800            });
20801          } catch (e) {}
20802        }
20803
20804        function restoreBaseRepoStats() {
20805          buttons.forEach(function (btn) {
20806            var f = btn.getAttribute('data-filter');
20807            var v = btn.querySelector('.scope-stat-value');
20808            if (v && originalStats[f]) v.textContent = originalStats[f];
20809          });
20810          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20811          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20812        }
20813
20814        submoduleChips.forEach(function (chip) {
20815          chip.addEventListener('click', function () {
20816            var statsJson = chip.getAttribute('data-sub-stats');
20817            if (!statsJson) return;
20818            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20819            chip.classList.add('active');
20820            applySubmoduleStats(statsJson);
20821            if (baseRepoBtn) baseRepoBtn.style.display = '';
20822          });
20823        });
20824
20825        if (baseRepoBtn) {
20826          baseRepoBtn.addEventListener('click', function () {
20827            restoreBaseRepoStats();
20828            resetViewState();
20829            sortSiblingRows();
20830            applyVisibility();
20831          });
20832        }
20833
20834        buttons.forEach(function (button) {
20835          button.addEventListener("click", function () {
20836            var filterValue = button.getAttribute("data-filter") || "all";
20837            if (filterValue === "reset-view") {
20838              restoreBaseRepoStats();
20839              resetViewState();
20840              sortSiblingRows();
20841              applyVisibility();
20842              return;
20843            }
20844            activeFilter = filterValue;
20845            applyVisibility();
20846          });
20847        });
20848
20849        rows.forEach(function (row) {
20850          updateToggleGlyph(row);
20851          var toggle = row.querySelector(".tree-toggle");
20852          if (toggle) {
20853            toggle.addEventListener("click", function () {
20854              var expanded = row.getAttribute("data-expanded") !== "false";
20855              row.setAttribute("data-expanded", expanded ? "false" : "true");
20856              updateToggleGlyph(row);
20857              applyVisibility();
20858            });
20859          }
20860        });
20861
20862        actionButtons.forEach(function (button) {
20863          button.addEventListener("click", function () {
20864            var action = button.getAttribute("data-explorer-action");
20865            if (action === "expand-all") {
20866              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20867            } else if (action === "collapse-all") {
20868              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20869            } else if (action === "clear-filters") {
20870              resetViewState();
20871            }
20872            sortSiblingRows();
20873            applyVisibility();
20874          });
20875        });
20876
20877        if (filterSelect) {
20878          filterSelect.addEventListener("change", function () {
20879            activeFilter = filterSelect.value || "all";
20880            applyVisibility();
20881          });
20882        }
20883
20884        languageButtons.forEach(function (button) {
20885          button.addEventListener("click", function () {
20886            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20887            updateLanguageButtons();
20888            applyVisibility();
20889          });
20890        });
20891
20892        sortButtons.forEach(function (button) {
20893          button.addEventListener("click", function () {
20894            var sortKey = button.getAttribute("data-sort-key");
20895            if (currentSortKey === sortKey) {
20896              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20897            } else {
20898              currentSortKey = sortKey;
20899              currentSortOrder = "asc";
20900            }
20901            sortSiblingRows();
20902            applyVisibility();
20903          });
20904        });
20905
20906        if (searchInput) {
20907          searchInput.addEventListener("input", function () {
20908            searchTerm = searchInput.value.trim().toLowerCase();
20909            applyVisibility();
20910          });
20911        }
20912
20913        updateLanguageButtons();
20914        sortSiblingRows();
20915        applyVisibility();
20916      }
20917
20918      function loadPreview() {
20919        if (!previewPanel || !pathInput) return;
20920        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20921        multiRepoBlocked = false;
20922        refreshStep1Gate();
20923        if (GIT_MODE) {
20924          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>';
20925          setPreviewLoading(false);
20926          return;
20927        }
20928        var path = pathInput.value.trim();
20929        var zeroWarn = document.getElementById('zero-files-warning');
20930        if (!path) {
20931          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20932          if (zeroWarn) zeroWarn.style.display = 'none';
20933          setPreviewLoading(false);
20934          return;
20935        }
20936        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20937        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20938        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20939        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20940        var myGen = ++_previewGen;
20941        var _prevMsgs = [
20942          'Scanning directory structure\u2026',
20943          'Detecting file types\u2026',
20944          'Applying include / exclude filters\u2026',
20945          'Estimating file counts\u2026',
20946          'Building scope preview\u2026',
20947          'Almost there\u2026'
20948        ];
20949        var _prevMsgIdx = 0;
20950        var _prevStart = Date.now();
20951        previewPanel.innerHTML =
20952          '<div class="preview-loading">' +
20953          '<div class="preview-spinner"></div>' +
20954          '<div class="preview-loading-text">' +
20955          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20956          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20957          '</div></div>';
20958        var _sizeTextEl = document.getElementById('project-size-text');
20959        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20960        window._previewInterval = setInterval(function() {
20961          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20962          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20963          var ml = document.getElementById('plm');
20964          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20965        }, 1500);
20966        window._previewElapsedTimer = setInterval(function() {
20967          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20968          var el = document.getElementById('ple');
20969          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20970        }, 1000);
20971        setPreviewLoading(true);
20972        var previewUrl = "/preview?path=" + encodeURIComponent(path)
20973          + "&include_globs=" + encodeURIComponent(includeValue)
20974          + "&exclude_globs=" + encodeURIComponent(excludeValue);
20975        fetch(previewUrl)
20976          .then(function (response) { return response.text(); })
20977          .then(function (html) {
20978            if (myGen !== _previewGen) return;
20979            clearInterval(window._previewInterval); window._previewInterval = null;
20980            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20981            setPreviewLoading(false);
20982            previewPanel.innerHTML = html;
20983            attachPreviewInteractions();
20984            syncPythonVisibility();
20985            updateReview();
20986            setTimeout(collapseLanguagePills, 50);
20987            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20988            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20989            var sizeText = document.getElementById('project-size-text');
20990            var sizeBtn = document.getElementById('project-size-btn');
20991            // In server mode with upload sizes available, keep the compressed/original pair.
20992            if (SERVER_MODE && window._lastUploadSizes) {
20993              var us = window._lastUploadSizes;
20994              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20995                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20996              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20997                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20998            } else if (sizeText && projectSize) {
20999              sizeText.textContent = 'Project size: ' + projectSize;
21000              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
21001            } else if (sizeText) {
21002              sizeText.textContent = 'Project size: \u2014';
21003            }
21004            if (zeroWarn) {
21005              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
21006              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
21007              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
21008              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
21009              if (supportedCount === 0 && fileCount > 0) {
21010                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).';
21011                zeroWarn.style.display = '';
21012              } else {
21013                zeroWarn.style.display = 'none';
21014              }
21015            }
21016          })
21017          .catch(function (err) {
21018            if (myGen !== _previewGen) return;
21019            clearInterval(window._previewInterval); window._previewInterval = null;
21020            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
21021            setPreviewLoading(false);
21022            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
21023          });
21024      }
21025
21026      function pickDirectory(targetInput, kind) {
21027        if (!targetInput) {
21028          showBannerToast("Directory picker: input element not found.", true);
21029          return;
21030        }
21031        if (SERVER_MODE) {
21032          if (kind === 'output') {
21033            showBannerToast(
21034              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
21035              false,
21036              { top: true, icon: '\u{1F4C1}' }
21037            );
21038            return;
21039          }
21040          var inputEl = kind === 'coverage'
21041            ? document.getElementById('cov-upload-input')
21042            : document.getElementById('dir-upload-input');
21043          if (!inputEl) return;
21044          inputEl.onchange = function () {
21045            var files = inputEl.files;
21046            if (!files || files.length === 0) return;
21047            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
21048            if (browseBtn) browseBtn.disabled = true;
21049
21050            function fileToBase64(file) {
21051              return new Promise(function (resolve, reject) {
21052                var reader = new FileReader();
21053                reader.onload = function () {
21054                  var b64 = reader.result.split(',')[1];
21055                  resolve(b64);
21056                };
21057                reader.onerror = reject;
21058                reader.readAsDataURL(file);
21059              });
21060            }
21061
21062            if (kind === 'coverage') {
21063              var f = files[0];
21064              if (previewPanel && targetInput === pathInput)
21065                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
21066              fileToBase64(f).then(function (b64) {
21067                return fetch('/api/upload-file', {
21068                  method: 'POST',
21069                  headers: { 'Content-Type': 'application/json' },
21070                  body: JSON.stringify({ filename: f.name, content: b64 })
21071                }).then(function (r) { return r.json(); });
21072              })
21073                .then(function (d) {
21074                  if (d && d.tmp_path) {
21075                    if (coverageInput) coverageInput.value = d.tmp_path;
21076                    setCovStatus('idle');
21077                  } else if (d && d.error) { showBannerToast(d.error, true); }
21078                })
21079                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
21080                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
21081            } else {
21082              // ── Filter to source-code files only ─────────────────────────
21083              // Binary, generated, and dependency files (node_modules, .git,
21084              // build artifacts) are skipped so they are never uploaded.
21085              var CODE_EXTS = new Set([
21086                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21087                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21088                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21089                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21090                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21091                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
21092                'tf','hcl','proto','thrift','avsc','graphql','gql'
21093              ]);
21094              var codeFiles = [];
21095              for (var i = 0; i < files.length; i++) {
21096                var f = files[i];
21097                var name = f.name;
21098                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
21099                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
21100                  codeFiles.push(f); continue;
21101                }
21102                var dot = name.lastIndexOf('.');
21103                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
21104              }
21105              // Collect specific .git metadata files for server-side git detection.
21106              // These have no source extension so they are excluded by the loop above,
21107              // but the server needs them to read branch/commit/author without running git.
21108              var gitMetaFiles = [];
21109              for (var i = 0; i < files.length; i++) {
21110                var f = files[i];
21111                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
21112                var gitIdx = rp.indexOf('/.git/');
21113                if (gitIdx < 0) continue;
21114                var gitRel = rp.slice(gitIdx + 1);
21115                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
21116                    gitRel === '.git/logs/HEAD' ||
21117                    gitRel.startsWith('.git/refs/heads/') ||
21118                    gitRel.startsWith('.git/refs/tags/')) {
21119                  gitMetaFiles.push(f);
21120                }
21121              }
21122              var uploadFiles = codeFiles.concat(gitMetaFiles);
21123              var total = files.length;
21124              var kept = codeFiles.length;
21125              if (kept === 0) {
21126                if (previewPanel && targetInput === pathInput)
21127                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
21128                if (browseBtn) browseBtn.disabled = false;
21129                inputEl.value = '';
21130                return;
21131              }
21132
21133              // ── Helper: apply upload result to UI ────────────────────────
21134              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
21135              function applyUploadResult(tmpPath, sizes) {
21136                targetInput.value = tmpPath;
21137                scrollInputToEnd(targetInput);
21138                if (sizes && SERVER_MODE) {
21139                  window._lastUploadSizes = sizes;
21140                  // Immediately show both sizes before preview loads.
21141                  var sizeText = document.getElementById('project-size-text');
21142                  var sizeBtn = document.getElementById('project-size-btn');
21143                  if (sizeText) {
21144                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21145                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21146                  }
21147                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21148                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21149                }
21150                if (targetInput === pathInput) {
21151                  updateReportTitleFromPath();
21152                  autoSetOutputDir(tmpPath);
21153                  fetchProjectHistory(tmpPath);
21154                  loadPreview();
21155                  suggestCoverageFile(tmpPath);
21156                }
21157                updateReview();
21158                if (browseBtn) browseBtn.disabled = false;
21159                inputEl.value = '';
21160              }
21161
21162              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
21163              if (typeof CompressionStream !== 'undefined') {
21164                if (previewPanel && targetInput === pathInput)
21165                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21166
21167                // Build a minimal POSIX ustar tar header for a single file entry.
21168                function buildUstarHeader(filePath, fileSize) {
21169                  var BLOCK = 512;
21170                  var hdr = new Uint8Array(BLOCK);
21171                  var enc = new TextEncoder();
21172                  function wStr(off, len, s) {
21173                    var b = enc.encode(s);
21174                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
21175                  }
21176                  function wOct(off, len, val) {
21177                    var s = val.toString(8);
21178                    while (s.length < len - 1) s = '0' + s;
21179                    wStr(off, len, s + '\0');
21180                  }
21181                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
21182                  var name = filePath, prefix = '';
21183                  if (filePath.length > 99) {
21184                    var split = filePath.lastIndexOf('/', 154);
21185                    if (split > 0 && filePath.length - split - 1 <= 99) {
21186                      prefix = filePath.substring(0, split);
21187                      name   = filePath.substring(split + 1);
21188                    } else { name = filePath.substring(0, 99); }
21189                  }
21190                  wStr(0,   100, name);          // name
21191                  wOct(100,   8, 0o000644);      // mode
21192                  wOct(108,   8, 0);             // uid
21193                  wOct(116,   8, 0);             // gid
21194                  wOct(124,  12, fileSize);      // size
21195                  wOct(136,  12, 0);             // mtime (epoch)
21196                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
21197                  hdr[156] = 48;                 // type flag '0' = regular file
21198                  wStr(157, 100, '');            // linkname
21199                  wStr(257,   6, 'ustar');       // magic
21200                  wStr(263,   2, '00');          // version
21201                  wStr(265,  32, '');            // uname
21202                  wStr(297,  32, '');            // gname
21203                  wOct(329,   8, 0);             // devmajor
21204                  wOct(337,   8, 0);             // devminor
21205                  wStr(345, 155, prefix);        // prefix
21206                  // Compute checksum (sum of all bytes, placeholder = 32).
21207                  var chk = 0;
21208                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21209                  var cs = chk.toString(8);
21210                  while (cs.length < 6) cs = '0' + cs;
21211                  wStr(148, 8, cs + '\0 ');
21212                  return hdr;
21213                }
21214
21215                // Build tar.gz one file at a time, piping through CompressionStream.
21216                // RAM usage = compressed output buffer + one file at a time.
21217                (async function () {
21218                  try {
21219                    var BLOCK = 512;
21220                    var cs     = new CompressionStream('gzip');
21221                    var writer = cs.writable.getWriter();
21222                    var chunks = [];
21223                    var reader = cs.readable.getReader();
21224                    var collecting = (async function () {
21225                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
21226                    })();
21227
21228                    for (var i = 0; i < uploadFiles.length; i++) {
21229                      var file = uploadFiles[i];
21230                      var path = file.webkitRelativePath || file.name;
21231                      var buf  = await file.arrayBuffer();
21232                      var data = new Uint8Array(buf);
21233                      // Header block
21234                      await writer.write(buildUstarHeader(path, data.length));
21235                      // Data padded to 512-byte boundary
21236                      if (data.length > 0) {
21237                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21238                        var block  = new Uint8Array(padded);
21239                        block.set(data);
21240                        await writer.write(block);
21241                      }
21242                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21243                        if (previewPanel && targetInput === pathInput)
21244                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21245                      }
21246                    }
21247                    // End-of-archive: two 512-byte zero blocks
21248                    await writer.write(new Uint8Array(BLOCK * 2));
21249                    await writer.close();
21250                    await collecting;
21251
21252                    var blob = new Blob(chunks, { type: 'application/gzip' });
21253                    var sizeMB = (blob.size / 1048576).toFixed(1);
21254                    if (previewPanel && targetInput === pathInput)
21255                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21256
21257                    var resp = await fetch('/api/upload-tarball', {
21258                      method: 'POST',
21259                      headers: { 'Content-Type': 'application/gzip' },
21260                      body: blob
21261                    });
21262                    var d = await resp.json();
21263                    if (d && d.tmp_path) {
21264                      applyUploadResult(d.tmp_path, {
21265                        compressed_bytes: d.compressed_bytes || 0,
21266                        original_bytes: d.original_bytes || 0
21267                      });
21268                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21269                  } catch (e) {
21270                    showBannerToast('Upload failed: ' + String(e), true);
21271                    if (browseBtn) browseBtn.disabled = false;
21272                    inputEl.value = '';
21273                  }
21274                })();
21275
21276              } else {
21277                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21278                // Used only on browsers that lack CompressionStream (pre-2023).
21279                var BATCH = 200;
21280                var batches = [];
21281                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21282                var totalBatches = batches.length;
21283                if (previewPanel && targetInput === pathInput)
21284                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21285
21286                function sendBatch(idx, currentUploadId, lastTmpPath) {
21287                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21288                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
21289                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21290                  Promise.all(batches[idx].map(function (file) {
21291                    return fileToBase64(file).then(function (b64) {
21292                      return { path: file.webkitRelativePath || file.name, content: b64 };
21293                    });
21294                  })).then(function (fileList) {
21295                    var body = { files: fileList };
21296                    if (currentUploadId) body.upload_id = currentUploadId;
21297                    return fetch('/api/upload-directory', {
21298                      method: 'POST', headers: { 'Content-Type': 'application/json' },
21299                      body: JSON.stringify(body)
21300                    }).then(function (r) { return r.json(); });
21301                  }).then(function (d) {
21302                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21303                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21304                  }).catch(function (e) {
21305                    showBannerToast('Upload failed: ' + String(e), true);
21306                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21307                  });
21308                }
21309                sendBatch(0, null, '');
21310              }
21311            }
21312          };
21313          inputEl.click();
21314          return;
21315        }
21316
21317        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21318        if (browseButton) browseButton.disabled = true;
21319
21320        if (previewPanel && targetInput === pathInput) {
21321          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21322        }
21323
21324        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
21325          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21326          .then(function (data) {
21327            if (data && data.selected_path) {
21328              targetInput.value = data.selected_path;
21329              scrollInputToEnd(targetInput);
21330
21331              if (targetInput === pathInput) {
21332                updateReportTitleFromPath();
21333                autoSetOutputDir(data.selected_path);
21334                fetchProjectHistory(data.selected_path);
21335                loadPreview();
21336                suggestCoverageFile(data.selected_path);
21337              }
21338
21339              updateReview();
21340            } else if (targetInput === pathInput) {
21341              loadPreview();
21342            }
21343          })
21344          .catch(function () {
21345            window.alert("Directory picker request failed.");
21346            if (previewPanel && targetInput === pathInput) {
21347              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21348            }
21349          })
21350          .finally(function () {
21351            if (browseButton) browseButton.disabled = false;
21352          });
21353      }
21354
21355      if (themeToggle) {
21356        themeToggle.addEventListener("click", function () {
21357          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21358          applyTheme(nextTheme);
21359          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21360        });
21361      }
21362
21363      stepButtons.forEach(function (button) {
21364        button.addEventListener("click", function () {
21365          var target = Number(button.getAttribute("data-step-target"));
21366          // Block jumping forward off step 1 while the preview / upload is running
21367          // or while a multi-repository selection is unacknowledged.
21368          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21369          setStep(target);
21370        });
21371      });
21372
21373      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21374        button.addEventListener("click", function () {
21375          var target = Number(button.getAttribute("data-step-target")) || 1;
21376          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21377          setStep(target);
21378        });
21379      });
21380
21381      // True when the project path is untouched from the bundled sample default.
21382      function isDefaultSamplePath() {
21383        return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21384      }
21385
21386      var defaultPathOverlay = document.getElementById("default-path-overlay");
21387      function closeDefaultPathModal() {
21388        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21389      }
21390      function openDefaultPathModal() {
21391        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21392      }
21393
21394      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21395        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21396        // that borrow the .next-step style class but carry no data-next target).
21397        if (!button.hasAttribute("data-next")) return;
21398        button.addEventListener("click", function () {
21399          // Guard step 1 → 2: block while the scope preview / upload is still running
21400          // or while a multi-repository selection is unacknowledged.
21401          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21402          // Guard step 1 → 2: warn when the project path is still the sample default.
21403          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21404            openDefaultPathModal();
21405            return;
21406          }
21407          updateReview();
21408          setStep(Number(button.getAttribute("data-next")));
21409        });
21410      });
21411
21412      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21413        if (!button.hasAttribute("data-prev")) return;
21414        button.addEventListener("click", function () {
21415          setStep(Number(button.getAttribute("data-prev")));
21416        });
21417      });
21418
21419      // Default-sample-path confirmation modal wiring.
21420      var defaultPathProceed = document.getElementById("default-path-proceed");
21421      if (defaultPathProceed) {
21422        defaultPathProceed.addEventListener("click", function () {
21423          closeDefaultPathModal();
21424          updateReview();
21425          setStep(2);
21426        });
21427      }
21428      var defaultPathCancel = document.getElementById("default-path-cancel");
21429      if (defaultPathCancel) {
21430        defaultPathCancel.addEventListener("click", function () {
21431          closeDefaultPathModal();
21432          if (pathInput) { pathInput.focus(); pathInput.select(); }
21433        });
21434      }
21435      if (defaultPathOverlay) {
21436        defaultPathOverlay.addEventListener("click", function (e) {
21437          if (e.target === defaultPathOverlay) closeDefaultPathModal();
21438        });
21439      }
21440      document.addEventListener("keydown", function (e) {
21441        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21442          closeDefaultPathModal();
21443        }
21444      });
21445
21446      document.addEventListener("keydown", function (e) {
21447        var tag = (document.activeElement || {}).tagName || "";
21448        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21449        if (e.altKey || e.ctrlKey || e.metaKey) return;
21450        if (e.key === "ArrowRight" && currentStep < 4) {
21451          if (currentStep === 1 && step1ForwardBlocked()) return;
21452          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21453          updateReview(); setStep(currentStep + 1);
21454        }
21455        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21456      });
21457
21458      if (useSamplePath) {
21459        useSamplePath.addEventListener("click", function () {
21460          pathInput.value = "testing/fixtures/basic";
21461          updateReportTitleFromPath();
21462          autoSetOutputDir("testing/fixtures/basic");
21463          loadPreview();
21464          suggestCoverageFile("testing/fixtures/basic");
21465        });
21466      }
21467
21468      if (useDefaultOutput) {
21469        useDefaultOutput.addEventListener("click", function () {
21470          delete outputDirInput.dataset.userEdited;
21471          autoSetOutputDir(pathInput ? pathInput.value : "");
21472          updateReview();
21473        });
21474      }
21475
21476      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21477      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21478
21479      // ── Drag-and-drop directory upload (server mode only) ─────────────────
21480      // Dropping a folder onto the path field bypasses Chrome's
21481      // "Upload X files to this site?" confirmation dialog.
21482      async function readDirRecursively(dirEntry, basePath) {
21483        var reader = dirEntry.createReader();
21484        var all = [];
21485        for (;;) {
21486          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21487          if (!batch.length) break;
21488          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21489        }
21490        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21491        var out = [];
21492        for (var i = 0; i < all.length; i++) {
21493          var sub = all[i];
21494          if (sub.isFile) {
21495            var f = await new Promise(function(res) { sub.file(res); });
21496            out.push({ file: f, path: basePath + '/' + sub.name });
21497          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21498            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21499            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21500          }
21501        }
21502        return out;
21503      }
21504
21505      function setupPathDropZone() {
21506        if (!SERVER_MODE || !pathInput) return;
21507        var CODE_EXTS = new Set([
21508          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21509          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21510          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21511          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21512          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21513          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21514        ]);
21515        pathInput.addEventListener('dragover', function(e) {
21516          e.preventDefault();
21517          pathInput.classList.add('drag-over');
21518        });
21519        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21520        pathInput.addEventListener('drop', function(e) {
21521          e.preventDefault();
21522          pathInput.classList.remove('drag-over');
21523          var items = e.dataTransfer.items;
21524          if (!items || !items.length) return;
21525          var dirEntry = null;
21526          for (var i = 0; i < items.length; i++) {
21527            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21528            if (entry && entry.isDirectory) { dirEntry = entry; break; }
21529          }
21530          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21531          var btn = browsePath;
21532          if (btn) btn.disabled = true;
21533          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21534
21535          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21536            var total = allEntries.length;
21537            var codeEntries = allEntries.filter(function(e) {
21538              var n = e.file.name;
21539              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21540              var dot = n.lastIndexOf('.');
21541              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21542            });
21543            var kept = codeEntries.length;
21544            if (kept === 0) {
21545              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21546              if (btn) btn.disabled = false; return;
21547            }
21548
21549            function finish(tmpPath, sizes) {
21550              pathInput.value = tmpPath;
21551              scrollInputToEnd(pathInput);
21552              if (sizes) {
21553                window._lastUploadSizes = sizes;
21554                var sizeText = document.getElementById('project-size-text');
21555                var sizeBtn = document.getElementById('project-size-btn');
21556                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21557                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21558                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21559                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21560              }
21561              updateReportTitleFromPath();
21562              autoSetOutputDir(tmpPath);
21563              fetchProjectHistory(tmpPath);
21564              loadPreview();
21565              suggestCoverageFile(tmpPath);
21566              updateReview();
21567              if (btn) btn.disabled = false;
21568            }
21569
21570            if (typeof CompressionStream === 'undefined') {
21571              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21572              if (btn) btn.disabled = false; return;
21573            }
21574
21575            try {
21576              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21577              var BLOCK = 512;
21578              var cs = new CompressionStream('gzip');
21579              var wtr = cs.writable.getWriter();
21580              var chunks = [];
21581              var rdr = cs.readable.getReader();
21582              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21583
21584              function buildHdr(fp, sz) {
21585                var hdr = new Uint8Array(BLOCK);
21586                var enc = new TextEncoder();
21587                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]; }
21588                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21589                var nm = fp, pfx = '';
21590                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); } }
21591                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21592                for (var i = 148; i < 156; i++) hdr[i] = 32;
21593                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);
21594                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21595                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21596                return hdr;
21597              }
21598
21599              for (var i = 0; i < codeEntries.length; i++) {
21600                var ce = codeEntries[i];
21601                var buf = await ce.file.arrayBuffer();
21602                var data = new Uint8Array(buf);
21603                await wtr.write(buildHdr(ce.path, data.length));
21604                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21605                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21606                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21607              }
21608              await wtr.write(new Uint8Array(BLOCK * 2));
21609              await wtr.close();
21610              await collecting;
21611
21612              var blob = new Blob(chunks, { type: 'application/gzip' });
21613              var sizeMB = (blob.size / 1048576).toFixed(1);
21614              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21615              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21616              var d = await resp.json();
21617              if (d && d.tmp_path) {
21618                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21619              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21620            } catch (err) {
21621              showBannerToast('Upload failed: ' + String(err), true);
21622              if (btn) btn.disabled = false;
21623            }
21624          }).catch(function(err) {
21625            showBannerToast('Could not read folder: ' + String(err), true);
21626            if (btn) btn.disabled = false;
21627          });
21628        });
21629      }
21630      setupPathDropZone();
21631      if (browseCoverage) {
21632        browseCoverage.addEventListener("click", function () {
21633          pickDirectory(coverageInput || pathInput, "coverage");
21634        });
21635      }
21636
21637      function setCovStatus(state, opts) {
21638        if (!covScanStatus) return;
21639        opts = opts || {};
21640        covScanStatus.className = "cov-scan-status cov-scan-" + state;
21641        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21642        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>';
21643        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>';
21644        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>';
21645        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>';
21646        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21647        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21648        if (state === "scanning") {
21649          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21650        } else if (state === "found") {
21651          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21652          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21653          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21654          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21655        } else if (state === "hint") {
21656          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21657          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
21658          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>';
21659        } else if (state === "none") {
21660          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21661          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
21662        }
21663        html += '</div></div>';
21664        covScanStatus.innerHTML = html;
21665        if (state === "found") {
21666          var useBtn = covScanStatus.querySelector(".cov-scan-use");
21667          if (useBtn) useBtn.addEventListener("click", function () {
21668            if (coverageInput) coverageInput.value = "";
21669            covAutoFilled = false;
21670            setCovStatus("idle");
21671          });
21672        }
21673      }
21674
21675      function suggestCoverageFile(projectPath) {
21676        if (!coverageInput || !covScanStatus) return;
21677        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21678        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21679        clearTimeout(coverageSuggestTimer);
21680        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21681        setCovStatus("scanning");
21682        coverageSuggestTimer = setTimeout(function () {
21683          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21684            .then(function (r) { return r.json(); })
21685            .then(function (d) {
21686              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21687              if (!d) { setCovStatus("none"); return; }
21688              if (d.found) {
21689                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21690                setCovStatus("found", { found: d.found, tool: d.tool });
21691              } else if (d.tool && d.hint) {
21692                setCovStatus("hint", { tool: d.tool, hint: d.hint });
21693              } else {
21694                setCovStatus("none");
21695              }
21696            })
21697            .catch(function () { setCovStatus("idle"); });
21698        }, 600);
21699      }
21700
21701      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21702
21703      if (coverageInput) coverageInput.addEventListener("input", function () {
21704        covAutoFilled = false;
21705        if (!this.value.trim()) setCovStatus("idle");
21706      });
21707
21708      // ── Language pill overflow: collapse to "+N more" chip ─────────────
21709      function collapseLanguagePills() {
21710        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21711        rows.forEach(function(row) {
21712          // Remove any previous overflow chip
21713          var prev = row.querySelector('.lang-overflow-chip');
21714          if (prev) prev.remove();
21715          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21716          pills.forEach(function(p) { p.style.display = ''; });
21717          if (!pills.length) return;
21718
21719          // Measure after restoring all pills
21720          var containerRight = row.getBoundingClientRect().right;
21721          var hidden = [];
21722          for (var i = pills.length - 1; i >= 1; i--) {
21723            var rect = pills[i].getBoundingClientRect();
21724            if (rect.right > containerRight + 2) {
21725              hidden.unshift(pills[i]);
21726              pills[i].style.display = 'none';
21727            } else {
21728              break;
21729            }
21730          }
21731
21732          if (hidden.length) {
21733            var chip = document.createElement('button');
21734            chip.type = 'button';
21735            chip.className = 'language-pill lang-overflow-chip';
21736            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21737            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21738            row.appendChild(chip);
21739          }
21740        });
21741      }
21742
21743      // Run after preview loads (preview panel populates language pills)
21744      var _origLoadPreviewCb = window.__previewLoaded;
21745      document.addEventListener('previewLoaded', collapseLanguagePills);
21746      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21747      setTimeout(collapseLanguagePills, 400);
21748
21749      // ── Project history & output dir auto-set ──────────────────────────
21750      var wsOutputRoot   = document.getElementById("ws-output-root");
21751      var wsScanCount    = document.getElementById("ws-scan-count");
21752      var wsLastScan     = document.getElementById("ws-last-scan");
21753      var historyBadge   = document.getElementById("path-history-badge");
21754      var historyTimer   = null;
21755
21756      var wsOutputLink = document.getElementById("ws-output-link");
21757      function syncStripOutputRoot() {
21758        var val = outputDirInput ? outputDirInput.value : "";
21759        var display = val || "project/sloc";
21760        if (wsOutputRoot) wsOutputRoot.textContent = display;
21761        if (wsOutputLink) wsOutputLink.dataset.folder = val;
21762      }
21763
21764      function scrollInputToEnd(input) {
21765        if (!input) return;
21766        // Defer so the DOM has the new value before we measure scroll width.
21767        requestAnimationFrame(function () {
21768          input.scrollLeft = input.scrollWidth;
21769          input.selectionStart = input.selectionEnd = input.value.length;
21770        });
21771      }
21772
21773      function autoSetOutputDir(projectPath) {
21774        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21775        if (GIT_MODE && GIT_OUTPUT_DIR) {
21776          outputDirInput.value = GIT_OUTPUT_DIR;
21777          scrollInputToEnd(outputDirInput);
21778          syncStripOutputRoot();
21779          updateReview();
21780          return;
21781        }
21782        if (!projectPath || !projectPath.trim()) return;
21783        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21784        outputDirInput.value = cleaned + "/sloc";
21785        scrollInputToEnd(outputDirInput);
21786        syncStripOutputRoot();
21787        updateReview();
21788      }
21789
21790      var wsBranch = document.getElementById("ws-branch");
21791
21792      function fetchProjectHistory(projectPath) {
21793        if (!projectPath || !projectPath.trim()) {
21794          if (wsScanCount) wsScanCount.textContent = "\u2014";
21795          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
21796          if (wsBranch)    wsBranch.textContent    = "\u2014";
21797          if (historyBadge) historyBadge.style.display = "none";
21798          return;
21799        }
21800        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21801          .then(function (r) { return r.ok ? r.json() : null; })
21802          .then(function (data) {
21803            if (!data) return;
21804            var countStr = data.scan_count > 0
21805              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21806              : "never";
21807            var tsStr = data.last_scan_timestamp
21808              ? data.last_scan_timestamp.replace(" UTC","")
21809              : "\u2014";
21810            if (wsScanCount) wsScanCount.textContent = countStr;
21811            if (wsLastScan)  wsLastScan.textContent  = tsStr;
21812            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
21813            if (data.scan_count > 0) {
21814              if (historyBadge) {
21815                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21816                historyBadge.textContent = data.scan_count + " previous scan" +
21817                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21818                  "Last: " + (data.last_scan_timestamp || "\u2014") +
21819                  " \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.";
21820                historyBadge.className = "path-history-badge found";
21821                historyBadge.style.display = "";
21822              }
21823            } else {
21824              if (historyBadge) historyBadge.style.display = "none";
21825            }
21826          })
21827          .catch(function () {});
21828      }
21829
21830      function onPathChange() {
21831        var val = pathInput ? pathInput.value : "";
21832        // Discard stale upload sizes when the user edits the path manually.
21833        window._lastUploadSizes = null;
21834        updateReportTitleFromPath();
21835        autoSetOutputDir(val);
21836        updateSidebarSummary();
21837        clearTimeout(historyTimer);
21838        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21839        if (previewTimer) clearTimeout(previewTimer);
21840        previewTimer = setTimeout(loadPreview, 280);
21841        suggestCoverageFile(val);
21842      }
21843
21844      if (pathInput) {
21845        pathInput.addEventListener("input", onPathChange);
21846      }
21847
21848      if (outputDirInput) {
21849        outputDirInput.addEventListener("input", function () {
21850          outputDirInput.dataset.userEdited = "1";
21851          syncStripOutputRoot();
21852          updateReview();
21853        });
21854      }
21855
21856      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21857        if (!node) return;
21858        node.addEventListener("input", function () {
21859          updateReview();
21860          if (previewTimer) clearTimeout(previewTimer);
21861          previewTimer = setTimeout(loadPreview, 280);
21862        });
21863      });
21864
21865      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21866        var node = document.getElementById(id);
21867        if (node) node.addEventListener("change", updateReview);
21868      });
21869
21870      if (reportTitleInput) {
21871        reportTitleInput.addEventListener("input", function () {
21872          reportTitleTouched = reportTitleInput.value.trim().length > 0;
21873          updateReportTitleFromPath();
21874          updateReview();
21875        });
21876      }
21877
21878      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21879      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21880      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21881      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21882
21883      if (coverageInput) {
21884        coverageInput.addEventListener("input", function () {
21885          if (coverageInput.value.trim()) setCovStatus("idle");
21886        });
21887      }
21888
21889      if (form && loading && submitButton) {
21890        form.addEventListener("submit", function (e) {
21891          e.preventDefault();
21892          submitButton.disabled = true;
21893          submitButton.textContent = "Scanning...";
21894          startAsyncAnalysis(new FormData(form));
21895        });
21896      }
21897
21898      function openPath(folder) {
21899        if (!folder) return;
21900        fetch('/open-path?path=' + encodeURIComponent(folder))
21901          .then(function (r) { return r.json(); })
21902          .then(function (d) {
21903            if (d && d.server_mode_disabled)
21904              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21905          })
21906          .catch(function () {});
21907      }
21908
21909      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21910        btn.addEventListener('click', function () {
21911          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21912        });
21913      });
21914
21915      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21916      if (wsOutputLink) {
21917        wsOutputLink.addEventListener('click', function () {
21918          openPath(wsOutputLink.dataset.folder || '');
21919        });
21920      }
21921
21922      loadSavedTheme();
21923      updateMixedPolicyUI();
21924      updatePythonDocstringUI();
21925      applyScanPreset();
21926      updatePresetDescriptions();
21927      applyArtifactPreset();
21928      updateReview();
21929      updateScrollProgress(); // initialise bar to 0% (step 1)
21930      window.addEventListener("scroll", updateScrollProgress, { passive: true });
21931      onPathChange();         // seed output dir, history badge, and preview from initial path
21932      updateStepNav(1);
21933
21934      // Restore step from URL hash on initial load (e.g., back-forward cache)
21935      (function() {
21936        var hashMatch = location.hash.match(/^#step([1-4])$/);
21937        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21938      })();
21939
21940      (function randomizeWatermarks() {
21941        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21942        if (!wms.length) return;
21943        var placed = [];
21944        function tooClose(top, left) {
21945          for (var i = 0; i < placed.length; i++) {
21946            var dt = Math.abs(placed[i][0] - top);
21947            var dl = Math.abs(placed[i][1] - left);
21948            if (dt < 16 && dl < 12) return true;
21949          }
21950          return false;
21951        }
21952        function pick(leftBand) {
21953          for (var attempt = 0; attempt < 50; attempt++) {
21954            var top = Math.random() * 88 + 2;
21955            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21956            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21957          }
21958          var top = Math.random() * 88 + 2;
21959          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21960          placed.push([top, left]);
21961          return [top, left];
21962        }
21963        var half = Math.floor(wms.length / 2);
21964        wms.forEach(function (img, i) {
21965          var pos = pick(i < half);
21966          var size = Math.floor(Math.random() * 80 + 110);
21967          var rot = (Math.random() * 360).toFixed(1);
21968          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21969          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;
21970        });
21971      })();
21972
21973      (function spawnCodeParticles() {
21974        var container = document.getElementById('code-particles');
21975        if (!container) return;
21976        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'];
21977        for (var i = 0; i < 38; i++) {
21978          (function(idx) {
21979            var el = document.createElement('span');
21980            el.className = 'code-particle';
21981            el.textContent = snippets[idx % snippets.length];
21982            var left = Math.random() * 94 + 2;
21983            var top = Math.random() * 88 + 6;
21984            var dur = (Math.random() * 10 + 9).toFixed(1);
21985            var delay = (Math.random() * 18).toFixed(1);
21986            var rot = (Math.random() * 26 - 13).toFixed(1);
21987            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21988            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';
21989            container.appendChild(el);
21990          })(i);
21991        }
21992      })();
21993    })();
21994  </script>
21995  <script nonce="{{ csp_nonce }}">
21996    (function () {
21997      var raw = {{ prefill_json|safe }};
21998      if (!raw || typeof raw !== 'object' || !raw.path) return;
21999      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
22000      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
22001      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
22002      setVal('path', raw.path || '');
22003      setVal('include_globs', raw.include_globs || '');
22004      setVal('exclude_globs', raw.exclude_globs || '');
22005      setVal('output_dir', raw.output_dir || '');
22006      setVal('report_title', raw.report_title || '');
22007      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
22008      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
22009      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
22010      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
22011      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
22012      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
22013      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
22014      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
22015      setChecked('generate_html', raw.generate_html !== false);
22016      setChecked('generate_pdf', !!raw.generate_pdf);
22017      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
22018      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
22019      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
22020      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
22021      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
22022      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
22023      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
22024      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
22025      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
22026      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
22027      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
22028      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
22029      // Trigger dynamic UI updates after pre-fill.
22030      setTimeout(function () {
22031        var pathEl = document.getElementById('path');
22032        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
22033        var policyEl = document.getElementById('mixed_line_policy');
22034        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
22035      }, 80);
22036    })();
22037  </script>
22038  <script nonce="{{ csp_nonce }}">
22039  (function(){
22040    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'}];
22041    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);});}
22042    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22043    function init(){
22044      var btn=document.getElementById('settings-btn');if(!btn)return;
22045      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22046      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>';
22047      document.body.appendChild(m);
22048      var g=document.getElementById('scheme-grid');
22049      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);});
22050      var cl=document.getElementById('settings-close');
22051      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
22052      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');});
22053      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22054      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22055    }
22056    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22057  }());
22058  </script>
22059  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
22060    <div class="wb-ftip-arrow"></div>
22061    <span id="wb-ftip-text"></span>
22062  </div>
22063  <script nonce="{{ csp_nonce }}">(function(){
22064    var tip=document.getElementById('wb-ftip');
22065    var txt=document.getElementById('wb-ftip-text');
22066    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
22067    if(!tip||!txt)return;
22068    function pos(el){
22069      var r=el.getBoundingClientRect();
22070      tip.style.display='block';
22071      var tw=tip.offsetWidth;
22072      var lx=r.left+r.width/2-tw/2;
22073      if(lx<8)lx=8;
22074      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
22075      tip.style.left=lx+'px';
22076      tip.style.top=(r.bottom+8)+'px';
22077      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';}
22078    }
22079    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
22080      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
22081      el.addEventListener('mouseleave',function(){tip.style.display='none';});
22082    });
22083    window.addEventListener('blur',function(){tip.style.display='none';});
22084    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
22085  })();
22086  (function(){
22087    function fixArtifactHintSpacing(){
22088      var grid=document.querySelector('.artifact-grid');
22089      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
22090    }
22091    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
22092  }());
22093  (function(){
22094    var dot=document.getElementById('status-dot');
22095    var pingEl=document.getElementById('server-ping-ms');
22096    var tipEl=document.getElementById('server-tip-ping');
22097    var fm=document.getElementById('footer-mode');
22098    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)';}}
22099    function doPing(){
22100      var t0=performance.now();
22101      fetch('/healthz',{cache:'no-store'})
22102        .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);})
22103        .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)';}});
22104    }
22105    doPing();
22106    setInterval(doPing,5000);
22107    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');}
22108  })();
22109  </script>
22110  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
22111  <footer class="site-footer">
22112    local code analysis - metrics, history and reports
22113    &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>
22114    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22115    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22116    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22117    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22118  </footer>
22119</body>
22120</html>
22121"##,
22122    ext = "html"
22123)]
22124struct IndexTemplate {
22125    version: &'static str,
22126    prefill_json: String,
22127    csp_nonce: String,
22128    git_repo: String,
22129    git_ref: String,
22130    git_label_json: String,
22131    git_output_dir_json: String,
22132    server_mode: bool,
22133}
22134
22135// ── SplashTemplate ────────────────────────────────────────────────────────────
22136
22137#[derive(Template)]
22138#[template(
22139    source = r##"
22140<!doctype html>
22141<html lang="en">
22142<head>
22143  <meta charset="utf-8">
22144  <meta name="viewport" content="width=device-width, initial-scale=1">
22145  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
22146  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22147  <script type="application/ld+json">
22148  {
22149    "@context": "https://schema.org",
22150    "@type": "SoftwareApplication",
22151    "name": "oxide-sloc",
22152    "applicationCategory": "DeveloperApplication",
22153    "operatingSystem": "Windows, Linux",
22154    "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.",
22155    "softwareVersion": "{{ version }}",
22156    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
22157    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
22158    "url": "https://github.com/oxide-sloc/oxide-sloc",
22159    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
22160    "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",
22161    "programmingLanguage": "Rust",
22162    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
22163  }
22164  </script>
22165  <style nonce="{{ csp_nonce }}">
22166    :root {
22167      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22168      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22169      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22170      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22171      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22172    }
22173    body.dark-theme {
22174      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22175      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22176    }
22177    *{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;}
22178    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22179    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22180    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22181    .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;}
22182    @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));}}
22183    .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);}
22184    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22185    .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));}
22186    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22187    .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;}
22188    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22189    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22190    @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; } }
22191    .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;}
22192    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22193    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22194    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22195    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22196    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22197    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
22198    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22199    .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);}
22200    .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;}
22201    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22202    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22203    .settings-modal-body{padding:14px 16px 16px;}
22204    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22205    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22206    .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;}
22207    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22208    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22209    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22210    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22211    .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;}
22212    .tz-select:focus{border-color:var(--oxide);}
22213    .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;}
22214    .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;}
22215    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
22216    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22217    .hero{text-align:center;margin:0 auto 18px;}
22218    .hero-logo-wrap{display:inline-block;cursor:default;}
22219    .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;}
22220    .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;}
22221    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
22222    .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;}
22223    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%);}
22224    .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;
22225      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
22226      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
22227      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;}
22228    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
22229    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
22230    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;}
22231    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
22232    .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;}
22233    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22234    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22235    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22236    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22237    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22238    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22239    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22240    .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;}
22241    .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;}
22242    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22243    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22244    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22245    .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);}
22246    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22247    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22248    .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);}
22249    .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);}
22250    .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);}
22251    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22252    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22253    .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;}
22254    body.dark-theme .action-card-cta{color:var(--oxide);}
22255    .action-card.view .action-card-cta{color:var(--accent-2);}
22256    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22257    .action-card.compare .action-card-cta{color:#7c3aed;}
22258    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22259    .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);}
22260    .action-card.git-tools .action-card-cta{color:#15803d;}
22261    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22262    .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);}
22263    .action-card.trend .action-card-cta{color:#0e7490;}
22264    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22265    .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);}
22266    .action-card.automation .action-card-cta{color:#b45309;}
22267    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22268    .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);}
22269    .action-card.test-metrics .action-card-cta{color:#be185d;}
22270    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22271    .action-card:hover .action-card-cta{gap:12px;}
22272    .action-card.card-split{flex-direction:row;align-items:stretch;}
22273    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22274    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22275    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22276    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22277    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22278    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22279    .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;}
22280    .ac-badge.active{opacity:1;}
22281    .ac-badge.github{border-color:#555;color:#555;}
22282    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22283    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22284    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22285    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22286    body.dark-theme .ac-right-row{color:var(--muted);}
22287    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22288    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22289    .divider{height:1px;background:var(--line);margin:32px 0;}
22290    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22291    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22292    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22293    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22294      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22295    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22296    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22297    body.dark-theme .info-chip-val{color:var(--oxide);}
22298    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22299    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22300      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22301      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22302    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22303      border:6px solid transparent;border-top-color:var(--text);}
22304    .info-chip:hover .info-chip-tip{display:block;}
22305    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22306    .chip-slide.fading{filter:blur(5px);opacity:0;}
22307    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22308    .site-footer a{color:var(--muted);}
22309    .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;}
22310    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22311    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22312    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22313    .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;}
22314    .lan-badge.local{background:var(--oxide-2);}
22315    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22316    .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);}
22317    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22318    .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;}
22319    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22320    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22321    .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;}
22322    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22323    .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;}
22324    .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);}
22325    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22326    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22327    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22328    .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;}
22329    @media (max-height: 1100px) {
22330      .page{padding-top:10px;}
22331      .hero{margin-bottom:10px;}
22332      .hero-logo{width:54px;height:60px;}
22333      .hero-logo-shadow{width:42px;}
22334      .hero-title{font-size:28px;}
22335      .hero-subtitle{font-size:13px;}
22336      .card-sections{gap:12px;margin-bottom:6px;}
22337      .card-section-grid-2,.card-section-grid-3{gap:10px;}
22338      .action-card{padding:8px 15px 8px;}
22339      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22340      .action-card-icon svg{width:18px;height:18px;}
22341      .action-card-title{font-size:13px;}
22342      .action-card-desc{font-size:11px;margin-bottom:6px;}
22343      .action-card-cta{font-size:11px;}
22344      .ac-right-row{font-size:11px;}
22345      .divider{margin:14px 0;}
22346      .info-strip{gap:7px;margin-bottom:8px;}
22347      .info-chip{padding:7px 10px;}
22348      .info-chip-val{font-size:13px;}
22349      .info-chip-label{font-size:9px;}
22350      .site-footer{padding:8px 24px;font-size:12px;}
22351      .lan-local-hint{margin-top:8px;}
22352    }
22353    @media (max-height: 850px) {
22354      .page{padding-top:6px;}
22355      .hero{margin-bottom:6px;}
22356      .hero-logo{width:42px;height:46px;}
22357      .hero-title{font-size:22px;}
22358      .hero-subtitle{font-size:12px;}
22359      .card-sections{gap:10px;}
22360      .action-card-desc{margin-bottom:4px;}
22361      .divider{margin:8px 0;}
22362      .info-strip{margin-bottom:6px;}
22363      .lan-local-hint{margin-top:10px;}
22364    }
22365  </style>
22366</head>
22367<body>
22368  <div class="background-watermarks" aria-hidden="true">
22369    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22370    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22371    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22372    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22373    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22374    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22375    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22376  </div>
22377  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22378  <div class="top-nav">
22379    <div class="top-nav-inner">
22380      <a class="brand" href="/">
22381        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22382        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22383      </a>
22384      <div class="nav-right">
22385        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22386        <div class="nav-dropdown">
22387          <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>
22388          <div class="nav-dropdown-menu">
22389            <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>
22390          </div>
22391        </div>
22392        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22393        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22394        <div class="nav-dropdown">
22395          <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>
22396          <div class="nav-dropdown-menu">
22397            <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>
22398          </div>
22399        </div>
22400        <div class="server-status-wrap" id="server-status-wrap">
22401          <div class="nav-pill server-online-pill" id="server-status-pill">
22402            <span class="status-dot" id="status-dot"></span>
22403            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22404            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22405          </div>
22406          <div class="server-status-tip">
22407            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22408            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22409          </div>
22410        </div>
22411        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22412          <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>
22413        </button>
22414        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22415          <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>
22416          <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>
22417        </button>
22418      </div>
22419    </div>
22420  </div>
22421
22422  <div class="page">
22423    <div class="hero">
22424      <div class="hero-logo-wrap" id="hero-logo-wrap">
22425        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22426      </div>
22427      <div class="hero-logo-shadow"></div>
22428      <div class="hero-title-wrap">
22429        <div class="hero-title-aura" aria-hidden="true"></div>
22430        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22431      </div>
22432      <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>
22433    </div>
22434
22435    <div class="card-sections">
22436
22437      <div>
22438        <div class="card-section-label">Analysis</div>
22439        <div class="card-section-grid-2">
22440          <a class="action-card scan card-split" href="/scan-setup">
22441            <div class="action-card-left">
22442              <div class="action-card-icon">
22443                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22444              </div>
22445              <div class="action-card-title">Scan Project</div>
22446              <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>
22447              <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>
22448            </div>
22449            <div class="action-card-sep"></div>
22450            <div class="action-card-right">
22451              <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>
22452              <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>
22453              <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>
22454              <div class="ac-right-stat" id="acp-scan-stat"></div>
22455            </div>
22456          </a>
22457          <a class="action-card test-metrics card-split" href="/test-metrics">
22458            <div class="action-card-left">
22459              <div class="action-card-icon">
22460                <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>
22461              </div>
22462              <div class="action-card-title">Test Metrics</div>
22463              <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>
22464              <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>
22465            </div>
22466            <div class="action-card-sep"></div>
22467            <div class="action-card-right">
22468              <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>
22469              <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>
22470              <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>
22471              <div class="ac-right-stat" id="acp-test-stat"></div>
22472            </div>
22473          </a>
22474        </div>
22475      </div>
22476
22477      <div>
22478        <div class="card-section-label">Reports &amp; Insights</div>
22479        <div class="card-section-grid-3">
22480          <a class="action-card view" href="/view-reports">
22481            <div class="action-card-icon">
22482              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22483            </div>
22484            <div class="action-card-title">View Reports</div>
22485            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22486            <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>
22487          </a>
22488          <a class="action-card compare" href="/compare-scans">
22489            <div class="action-card-icon">
22490              <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>
22491            </div>
22492            <div class="action-card-title">Compare Scans</div>
22493            <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>
22494            <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>
22495          </a>
22496          <a class="action-card trend" href="/trend-reports">
22497            <div class="action-card-icon">
22498              <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>
22499            </div>
22500            <div class="action-card-title">Trend Report</div>
22501            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22502            <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>
22503          </a>
22504        </div>
22505      </div>
22506
22507      <div>
22508        <div class="card-section-label">Developer Tools</div>
22509        <div class="card-section-grid-2">
22510          <a class="action-card git-tools card-split" href="/git-browser">
22511            <div class="action-card-left">
22512              <div class="action-card-icon">
22513                <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>
22514              </div>
22515              <div class="action-card-title">Git Browser</div>
22516              <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>
22517              <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>
22518            </div>
22519            <div class="action-card-sep"></div>
22520            <div class="action-card-right">
22521              <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>
22522              <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>
22523              <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>
22524            </div>
22525          </a>
22526          <a class="action-card automation card-split" href="/integrations">
22527            <div class="action-card-left">
22528              <div class="action-card-icon">
22529                <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>
22530              </div>
22531              <div class="action-card-title">Integrations</div>
22532              <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>
22533              <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>
22534            </div>
22535            <div class="action-card-sep"></div>
22536            <div class="action-card-right">
22537              <div class="ac-badges-grid">
22538                <span class="ac-badge github"     id="acp-gh">GitHub</span>
22539                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
22540                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
22541                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22542              </div>
22543              <div class="ac-right-stat" id="acp-int-stat"></div>
22544            </div>
22545          </a>
22546        </div>
22547      </div>
22548
22549    </div>
22550
22551    {% if server_mode %}
22552    <div class="lan-card server">
22553      <div class="lan-card-header">
22554        <span class="lan-badge">LAN server</span>
22555        Accessible on your network
22556      </div>
22557      {% if let Some(ip) = lan_ip %}
22558      <div class="lan-url-row">
22559        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22560        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22561          <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>
22562          Copy URL
22563        </button>
22564      </div>
22565      <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>
22566      {% if has_api_key %}
22567      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
22568      {% endif %}
22569      {% else %}
22570      <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>
22571      {% endif %}
22572    </div>
22573    {% endif %}
22574
22575    <div class="divider"></div>
22576
22577    <div class="info-strip">
22578      <div class="info-chip">
22579        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22580        <div class="chip-slide">
22581          <div class="info-chip-val">60</div>
22582          <div class="info-chip-label">Languages</div>
22583        </div>
22584      </div>
22585      <div class="info-chip">
22586        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22587        <div class="chip-slide">
22588          <div class="info-chip-val">100%</div>
22589          <div class="info-chip-label">Self-contained</div>
22590        </div>
22591      </div>
22592      <div class="info-chip">
22593        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22594        <div class="chip-slide">
22595          <div class="info-chip-val">HTML+PDF</div>
22596          <div class="info-chip-label">Exportable reports</div>
22597        </div>
22598      </div>
22599      <div class="info-chip">
22600        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22601        <div class="chip-slide">
22602          <div class="info-chip-val">Webhook</div>
22603          <div class="info-chip-label">3 platforms</div>
22604        </div>
22605      </div>
22606      <div class="info-chip">
22607        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22608        <div class="chip-slide">
22609          <div class="info-chip-val">IEEE</div>
22610          <div class="info-chip-label">1045-1992</div>
22611        </div>
22612      </div>
22613    </div>
22614
22615    {% if lan_ip.is_none() %}
22616    <div class="lan-local-hint">
22617      <strong>Want teammates on the same network to access this?</strong><br>
22618      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
22619    </div>
22620    {% endif %}
22621  </div>
22622
22623  <footer class="site-footer">
22624    local code analysis - metrics, history and reports
22625    &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>
22626    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22627    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22628    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22629    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22630  </footer>
22631
22632  <script nonce="{{ csp_nonce }}">
22633    (function () {
22634      var storageKey = 'oxide-sloc-theme';
22635      var body = document.body;
22636      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22637      var toggle = document.getElementById('theme-toggle');
22638      if (toggle) toggle.addEventListener('click', function () {
22639        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22640        body.classList.toggle('dark-theme', next === 'dark');
22641        try { localStorage.setItem(storageKey, next); } catch(e) {}
22642      });
22643      var copyBtn = document.getElementById('lan-copy-btn');
22644      if (copyBtn) copyBtn.addEventListener('click', function() {
22645        var btn = this;
22646        var el = document.getElementById('lan-url-val');
22647        if (!el) return;
22648        var url = el.textContent.trim();
22649        if (navigator.clipboard) {
22650          navigator.clipboard.writeText(url).then(function() {
22651            var orig = btn.innerHTML;
22652            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!';
22653            setTimeout(function() { btn.innerHTML = orig; }, 1800);
22654          });
22655        }
22656      });
22657      (function randomizeWatermarks() {
22658        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22659        if (!wms.length) return;
22660        var placed = [];
22661        function tooClose(top, left) {
22662          for (var i = 0; i < placed.length; i++) {
22663            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22664            if (dt < 16 && dl < 12) return true;
22665          }
22666          return false;
22667        }
22668        function pick(leftBand) {
22669          for (var attempt = 0; attempt < 50; attempt++) {
22670            var top = Math.random() * 88 + 2;
22671            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22672            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22673          }
22674          var top = Math.random() * 88 + 2;
22675          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22676          placed.push([top, left]); return [top, left];
22677        }
22678        var half = Math.floor(wms.length / 2);
22679        wms.forEach(function (img, i) {
22680          var pos = pick(i < half);
22681          var size = Math.floor(Math.random() * 100 + 120);
22682          var rot = (Math.random() * 360).toFixed(1);
22683          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22684          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;
22685        });
22686      })();
22687
22688      (function spawnCodeParticles() {
22689        var container = document.getElementById('code-particles');
22690        if (!container) return;
22691        var snippets = [
22692          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22693          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22694          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22695          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22696          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22697        ];
22698        var count = 38;
22699        for (var i = 0; i < count; i++) {
22700          (function(idx) {
22701            var el = document.createElement('span');
22702            el.className = 'code-particle';
22703            var text = snippets[idx % snippets.length];
22704            el.textContent = text;
22705            var left = Math.random() * 94 + 2;
22706            var top = Math.random() * 88 + 6;
22707            var dur = (Math.random() * 10 + 9).toFixed(1);
22708            var delay = (Math.random() * 18).toFixed(1);
22709            var rot = (Math.random() * 26 - 13).toFixed(1);
22710            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22711            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22712              + '--rot:' + rot + 'deg;--op:' + op + ';'
22713              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22714            container.appendChild(el);
22715          })(i);
22716        }
22717      })();
22718      (function heroAnimations() {
22719        var sub = document.getElementById('hero-subtitle');
22720        if (sub) {
22721          var full = sub.textContent.trim();
22722          sub.textContent = '';
22723          sub.style.opacity = '1';
22724          var cursor = document.createElement('span');
22725          cursor.className = 'hero-cursor';
22726          sub.appendChild(cursor);
22727          var i = 0;
22728          setTimeout(function() {
22729            var iv = setInterval(function() {
22730              if (i < full.length) {
22731                sub.insertBefore(document.createTextNode(full[i]), cursor);
22732                i++;
22733              } else {
22734                clearInterval(iv);
22735                setTimeout(function() {
22736                  cursor.style.transition = 'opacity 1s ease';
22737                  cursor.style.opacity = '0';
22738                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22739                }, 2400);
22740              }
22741            }, 11);
22742          }, 374);
22743        }
22744      })();
22745      (function logoBob() {
22746        var logo = document.querySelector('.hero-logo');
22747        var shadow = document.querySelector('.hero-logo-shadow');
22748        if (!logo) return;
22749        var cycleStart = null, cycleDur = 3600;
22750        var peakY = -14, peakScale = 1.07, peakRot = 0;
22751        function newCycle() {
22752          cycleDur = 3000 + Math.random() * 1840;
22753          peakY = -(9 + Math.random() * 13.8);
22754          peakScale = 1.04 + Math.random() * 0.081;
22755          peakRot = (Math.random() * 11.5 - 5.75);
22756        }
22757        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22758        newCycle();
22759        function frame(ts) {
22760          if (cycleStart === null) cycleStart = ts;
22761          var t = (ts - cycleStart) / cycleDur;
22762          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22763          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22764          var y = peakY * phase;
22765          var sc = 1 + (peakScale - 1) * phase;
22766          var rot = peakRot * Math.sin(Math.PI * phase);
22767          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22768          if (shadow) {
22769            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22770            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22771          }
22772          requestAnimationFrame(frame);
22773        }
22774        requestAnimationFrame(frame);
22775      })();
22776      (function mouseEffects() {
22777        var heroTitle = document.getElementById('hero-title');
22778        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22779        function tick() {
22780          raf = null;
22781          if (heroTitle) {
22782            var r = heroTitle.getBoundingClientRect();
22783            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22784            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22785            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22786          }
22787        }
22788        document.addEventListener('mousemove', function(e) {
22789          mx = e.clientX; my = e.clientY;
22790          if (!raf) raf = requestAnimationFrame(tick);
22791        });
22792        document.addEventListener('mouseleave', function() {
22793          if (heroTitle) {
22794            heroTitle.style.transition = 'transform 0.5s ease';
22795            heroTitle.style.transform = '';
22796            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22797          }
22798        });
22799        document.querySelectorAll('.action-card').forEach(function(card) {
22800          card.addEventListener('mousemove', function(e) {
22801            var rect = card.getBoundingClientRect();
22802            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22803            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22804            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22805            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22806          });
22807          card.addEventListener('mouseleave', function() {
22808            card.style.transition = '';
22809            card.style.transform = '';
22810          });
22811        });
22812      })();
22813      (function chipSlideshow() {
22814        var slides = [
22815          [{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'}],
22816          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22817          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22818          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22819          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22820        ];
22821        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22822        var indices = [0,0,0,0,0];
22823        var paused = [false,false,false,false,false];
22824        chips.forEach(function(chip, i) {
22825          chip.addEventListener('mouseenter', function() { paused[i] = true; });
22826          chip.addEventListener('mouseleave', function() { paused[i] = false; });
22827        });
22828        function advance(i) {
22829          if (paused[i]) return;
22830          var chip = chips[i];
22831          var inner = chip.querySelector('.chip-slide');
22832          if (!inner) return;
22833          inner.classList.add('fading');
22834          setTimeout(function() {
22835            indices[i] = (indices[i] + 1) % slides[i].length;
22836            var s = slides[i][indices[i]];
22837            chip.querySelector('.info-chip-val').textContent = s.v;
22838            chip.querySelector('.info-chip-label').textContent = s.l;
22839            inner.classList.remove('fading');
22840          }, 720);
22841        }
22842        setInterval(function() {
22843          chips.forEach(function(chip, i) { advance(i); });
22844        }, 6000);
22845      })();
22846      (function cardLiveData() {
22847        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22848          var el = document.getElementById('acp-scan-stat');
22849          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22850        }).catch(function(){});
22851        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22852          var el = document.getElementById('acp-test-stat');
22853          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22854        }).catch(function(){});
22855        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22856          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22857          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22858          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22859          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22860          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22861          var stat = document.getElementById('acp-int-stat');
22862          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22863        }).catch(function(){});
22864        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22865          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22866        }).catch(function(){});
22867      })();
22868    })();
22869  </script>
22870  <script nonce="{{ csp_nonce }}">
22871  (function(){
22872    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'}];
22873    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);});}
22874    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22875    function init(){
22876      var btn=document.getElementById('settings-btn');if(!btn)return;
22877      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22878      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>';
22879      document.body.appendChild(m);
22880      var g=document.getElementById('scheme-grid');
22881      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);});
22882      var cl=document.getElementById('settings-close');
22883      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
22884      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');});
22885      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22886      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22887    }
22888    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22889  }());
22890  </script>
22891  <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>
22892</body>
22893</html>
22894"##,
22895    ext = "html"
22896)]
22897struct SplashTemplate {
22898    csp_nonce: String,
22899    server_mode: bool,
22900    lan_ip: Option<String>,
22901    port: u16,
22902    version: &'static str,
22903    has_api_key: bool,
22904}
22905
22906// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
22907
22908#[derive(Template)]
22909#[template(
22910    source = r##"
22911<!doctype html>
22912<html lang="en">
22913<head>
22914  <meta charset="utf-8">
22915  <meta name="viewport" content="width=device-width, initial-scale=1">
22916  <title>OxideSLOC — Start a Scan</title>
22917  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22918  <style nonce="{{ csp_nonce }}">
22919    :root {
22920      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22921      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22922      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22923      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22924      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22925    }
22926    body.dark-theme {
22927      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22928      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22929    }
22930    *{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;}
22931    .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);}
22932    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22933    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22934    .brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
22935    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22936    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22937    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22938    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22939    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22940    @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; } }
22941    .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;}
22942    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22943    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22944    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22945    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22946    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22947    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
22948    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22949    .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);}
22950    .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;}
22951    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22952    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22953    .settings-modal-body{padding:14px 16px 16px;}
22954    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22955    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22956    .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;}
22957    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22958    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22959    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22960    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22961    .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;}
22962    .tz-select:focus{border-color:var(--oxide);}
22963    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22964    .page-header{text-align:center;margin-bottom:16px;}
22965    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22966    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22967    /* Cards */
22968    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22969    .option-card-wrap{position:relative;}
22970    .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;}
22971    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22972    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22973    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22974    .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;}
22975    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22976    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22977    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22978    .card-top-row{display:flex;align-items:center;gap:20px;}
22979    /* Two-column layout inside each card */
22980    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22981    .card-left{display:flex;align-items:flex-start;min-width:0;}
22982    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22983    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22984    .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);}
22985    .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);}
22986    .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);}
22987    .card-text{min-width:0;}
22988    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22989    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22990    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22991    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22992    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22993    /* Right CTA column */
22994    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22995    .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;}
22996    /* Re-scan count badge */
22997    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22998    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22999    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
23000    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
23001    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
23002    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
23003    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
23004    body.dark-theme .btn-secondary{color:var(--oxide);}
23005    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
23006    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
23007    /* File input overlay — must be full-width so it aligns with other card-right buttons */
23008    .file-input-wrap{position:relative;width:100%;}
23009    .file-input-wrap .btn{width:100%;}
23010    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
23011    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23012    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23013    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23014    .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;}
23015    @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));}}
23016    /* Recent list (card 3 — full-width section below header) */
23017    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
23018    .recent-list{display:flex;flex-direction:column;gap:8px;}
23019    .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;}
23020    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
23021    .recent-item-info{flex:1;min-width:0;}
23022    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
23023    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
23024    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
23025    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
23026    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23027    .site-footer a{color:var(--muted);}
23028    @media(max-width:680px){
23029      .card-body{grid-template-columns:1fr;}
23030      .card-right{flex-direction:row;flex-wrap:wrap;}
23031      .btn{flex:1;}
23032    }
23033    .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;}
23034    .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;}
23035    .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;}
23036  </style>
23037</head>
23038<body>
23039  <div class="background-watermarks" aria-hidden="true">
23040    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23041    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23042    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23043    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23044    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23045    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23046    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23047  </div>
23048  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23049  <div class="top-nav">
23050    <div class="top-nav-inner">
23051      <a class="brand" href="/">
23052        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23053        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23054      </a>
23055      <div class="nav-right">
23056        <a class="nav-pill" href="/">Home</a>
23057        <div class="nav-dropdown">
23058          <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>
23059          <div class="nav-dropdown-menu">
23060            <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>
23061          </div>
23062        </div>
23063        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23064        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23065        <div class="nav-dropdown">
23066          <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>
23067          <div class="nav-dropdown-menu">
23068            <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>
23069          </div>
23070        </div>
23071        <div class="server-status-wrap" id="server-status-wrap">
23072          <div class="nav-pill server-online-pill" id="server-status-pill">
23073            <span class="status-dot" id="status-dot"></span>
23074            <span id="server-status-label">Server</span>
23075            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23076          </div>
23077          <div class="server-status-tip">
23078            OxideSLOC is running — accessible on your network.
23079            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23080          </div>
23081        </div>
23082        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23083          <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>
23084        </button>
23085        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23086          <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>
23087          <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>
23088        </button>
23089      </div>
23090    </div>
23091  </div>
23092
23093  <div class="page">
23094    <div class="page-header">
23095      <h1>How would you like to scan?</h1>
23096      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
23097    </div>
23098
23099    <div class="option-grid">
23100
23101      <!-- Option 1: New scan -->
23102      <div class="option-card-wrap">
23103        <div class="option-card">
23104        <div class="option-icon new-scan">
23105          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
23106        </div>
23107        <div class="card-body">
23108          <div class="card-left">
23109            <div class="card-text">
23110              <div class="option-title">Start a new scan</div>
23111              <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>
23112              <ul class="feature-list">
23113                <li>Live project scope preview before you run</li>
23114                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
23115                <li>HTML, PDF, and JSON output — your choice</li>
23116              </ul>
23117            </div>
23118          </div>
23119          <div class="card-right">
23120            <a class="btn btn-primary" href="/scan">
23121              Configure &amp; scan
23122              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
23123            </a>
23124            <p class="card-tip">Full 4-step setup · all options</p>
23125          </div>
23126        </div>
23127        </div>
23128      </div>
23129
23130      <!-- Option 2: Load from config file -->
23131      <div class="option-card-wrap">
23132        <div class="option-card">
23133        <div class="option-icon load-config">
23134          <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>
23135        </div>
23136        <div class="card-body">
23137          <div class="card-left">
23138            <div class="card-text">
23139              <div class="option-title">Load a saved config</div>
23140              <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>
23141              <ul class="feature-list">
23142                <li>All 15 settings restored from the file</li>
23143                <li>Fully editable — change path or output dir</li>
23144                <li>Works with any scan-config.json</li>
23145              </ul>
23146            </div>
23147          </div>
23148          <div class="card-right">
23149            <div class="file-input-wrap">
23150              <button class="btn btn-secondary" id="load-config-btn" type="button">
23151                <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>
23152                Choose config file
23153              </button>
23154              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
23155            </div>
23156            <p class="card-tip" id="config-file-name">Exported after every scan</p>
23157          </div>
23158        </div>
23159        </div>
23160      </div>
23161
23162      <!-- Option 3: Re-scan recent project -->
23163      <div class="option-card-wrap">
23164        <div class="option-card" id="recent-card">
23165        <div class="card-top-row">
23166          <div class="option-icon rescan">
23167            <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>
23168          </div>
23169          <div class="card-body">
23170            <div class="card-left">
23171              <div class="card-text">
23172                <div class="option-title">Re-scan a recent project</div>
23173                <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>
23174                <ul class="feature-list">
23175                  <li>All 15+ settings restored from the saved config</li>
23176                  <li>Path and output dir are editable before running</li>
23177                  <li>Only scans with a saved config appear here</li>
23178                </ul>
23179              </div>
23180            </div>
23181            <div class="card-right">
23182              <div class="rescan-count-box">
23183                <div class="rescan-count-num" id="rescan-count-num">—</div>
23184                <div class="rescan-count-label">saved configs</div>
23185              </div>
23186              <a class="btn btn-secondary" href="/view-reports">
23187                <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>
23188                View all runs
23189              </a>
23190              <p class="card-tip">Opens run history</p>
23191            </div>
23192          </div>
23193        </div>
23194        <div class="section-divider"></div>
23195        <div class="recent-list" id="recent-list">
23196          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
23197        </div>
23198        </div>
23199      </div>
23200
23201    </div>
23202  </div>
23203
23204  <footer class="site-footer">
23205    local code analysis - metrics, history and reports
23206    &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>
23207    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23208    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23209    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23210    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23211  </footer>
23212
23213  <script nonce="{{ csp_nonce }}">
23214    (function () {
23215      var storageKey = 'oxide-sloc-theme';
23216      var body = document.body;
23217      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
23218      var toggle = document.getElementById('theme-toggle');
23219      if (toggle) toggle.addEventListener('click', function () {
23220        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
23221        body.classList.toggle('dark-theme', next === 'dark');
23222        try { localStorage.setItem(storageKey, next); } catch(e) {}
23223      });
23224
23225      (function randomizeWatermarks() {
23226        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23227        if (!wms.length) return;
23228        var placed = [];
23229        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; }
23230        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]; }
23231        var half = Math.floor(wms.length / 2);
23232        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; });
23233      })();
23234      (function spawnCodeParticles() {
23235        var container = document.getElementById('code-particles');
23236        if (!container) return;
23237        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'];
23238        var count = 38;
23239        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); }
23240      })();
23241      // Recent scans data injected from server
23242      var recentScans = {{ recent_scans_json|safe }};
23243
23244      function configToParams(cfg) {
23245        var p = new URLSearchParams();
23246        p.set('prefilled', '1');
23247        if (cfg.path) p.set('path', cfg.path);
23248        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23249        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23250        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23251        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23252        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23253        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23254        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23255        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23256        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23257        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23258        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23259        if (cfg.report_title) p.set('report_title', cfg.report_title);
23260        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23261        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23262        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23263        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23264        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23265        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23266        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23267        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23268        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23269        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23270        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23271        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23272        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23273        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23274        return p;
23275      }
23276
23277      // Build recent scan list (capped at 3 visible entries)
23278      var list = document.getElementById('recent-list');
23279      var noNote = document.getElementById('no-recent-note');
23280      var hasAny = false;
23281      var MAX_RECENT = 3;
23282      if (Array.isArray(recentScans)) {
23283        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23284        var shown = 0;
23285        validEntries.forEach(function (entry) {
23286          if (shown >= MAX_RECENT) return;
23287          shown++;
23288          hasAny = true;
23289          var item = document.createElement('div');
23290          item.className = 'recent-item';
23291          item.title = 'Restore all settings and open wizard';
23292          item.innerHTML =
23293            '<div class="recent-item-info">' +
23294              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23295              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
23296            '</div>' +
23297            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23298          item.addEventListener('click', function () {
23299            var params = configToParams(entry.config);
23300            window.location.href = '/scan?' + params.toString();
23301          });
23302          list.appendChild(item);
23303        });
23304        if (validEntries.length > MAX_RECENT) {
23305          var moreEl = document.createElement('div');
23306          moreEl.className = 'recent-more-link';
23307          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
23308          list.appendChild(moreEl);
23309        }
23310      }
23311      if (hasAny && noNote) noNote.style.display = 'none';
23312      // Update count badge
23313      var countEl = document.getElementById('rescan-count-num');
23314      if (countEl) {
23315        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23316        countEl.textContent = total > 0 ? total : '0';
23317      }
23318
23319      // Config file loader
23320      var fileInput = document.getElementById('config-file-input');
23321      var fileName = document.getElementById('config-file-name');
23322      var loadBtn = document.getElementById('load-config-btn');
23323      // Wire the visible button to open the hidden file picker.
23324      if (loadBtn && fileInput) {
23325        loadBtn.addEventListener('click', function () { fileInput.click(); });
23326      }
23327      if (fileInput) {
23328        fileInput.addEventListener('change', function () {
23329          var file = fileInput.files && fileInput.files[0];
23330          if (!file) return;
23331          if (fileName) fileName.textContent = '\u2713 ' + file.name;
23332          var reader = new FileReader();
23333          reader.onload = function (e) {
23334            try {
23335              var cfg = JSON.parse(e.target.result);
23336              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23337              var params = configToParams(cfg);
23338              window.location.href = '/scan?' + params.toString();
23339            } catch (err) {
23340              alert('Could not parse config file: ' + err.message);
23341            }
23342          };
23343          reader.readAsText(file);
23344        });
23345      }
23346
23347      function escHtml(s) {
23348        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
23349      }
23350    })();
23351  </script>
23352  <script nonce="{{ csp_nonce }}">
23353  (function(){
23354    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'}];
23355    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);});}
23356    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23357    function init(){
23358      var btn=document.getElementById('settings-btn');if(!btn)return;
23359      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23360      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>';
23361      document.body.appendChild(m);
23362      var g=document.getElementById('scheme-grid');
23363      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);});
23364      var cl=document.getElementById('settings-close');
23365      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
23366      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');});
23367      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23368      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23369    }
23370    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23371  }());
23372  </script>
23373  <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]';
23374  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;}
23375  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>
23376</body>
23377</html>
23378"##,
23379    ext = "html"
23380)]
23381struct ScanSetupTemplate {
23382    version: &'static str,
23383    recent_scans_json: String,
23384    csp_nonce: String,
23385}
23386
23387#[derive(Template)]
23388#[template(
23389    source = r##"
23390<!doctype html>
23391<html lang="en">
23392<head>
23393  <meta charset="utf-8">
23394  <meta name="viewport" content="width=device-width, initial-scale=1">
23395  <title>OxideSLOC | {{ report_title }} | Report</title>
23396  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23397  <style nonce="{{ csp_nonce }}">
23398    :root {
23399      --radius: 18px;
23400      --bg: #f5efe8;
23401      --surface: rgba(255,255,255,0.82);
23402      --surface-2: #fbf7f2;
23403      --surface-3: #efe6dc;
23404      --line: #e6d0bf;
23405      --line-strong: #dcb89f;
23406      --text: #43342d;
23407      --muted: #7b675b;
23408      --muted-2: #a08777;
23409      --nav: #b85d33;
23410      --nav-2: #7a371b;
23411      --accent: #6f9bff;
23412      --accent-2: #4a78ee;
23413      --oxide: #d37a4c;
23414      --oxide-2: #b35428;
23415      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23416      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23417      --success-bg: #e8f5ed;
23418      --success-text: #1a8f47;
23419      --info-bg: #eef3ff;
23420      --info-text: #4467d8;
23421    }
23422
23423    body.dark-theme {
23424      --bg: #1b1511;
23425      --surface: #261c17;
23426      --surface-2: #2d221d;
23427      --surface-3: #372922;
23428      --line: #524238;
23429      --line-strong: #6c5649;
23430      --text: #f5ece6;
23431      --muted: #c7b7aa;
23432      --muted-2: #aa9485;
23433      --nav: #b85d33;
23434      --nav-2: #7a371b;
23435      --accent: #6f9bff;
23436      --accent-2: #4a78ee;
23437      --oxide: #d37a4c;
23438      --oxide-2: #b35428;
23439      --shadow: 0 18px 42px rgba(0,0,0,0.28);
23440      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23441      --success-bg: #163927;
23442      --success-text: #8fe2a8;
23443      --info-bg: #1c2847;
23444      --info-text: #a9c1ff;
23445    }
23446
23447    * { box-sizing: border-box; }
23448    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); }
23449    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23450    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23451    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23452    .top-nav, .page { position: relative; z-index: 2; }
23453    .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); }
23454    .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; }
23455    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23456    .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)); }
23457    .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; }
23458    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23459    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23460    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23461    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23462    .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; }
23463    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23464    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23465    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23466    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23467    @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; } }
23468    .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; }
23469    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23470    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23471    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23472    .theme-toggle .icon-sun { display:none; }
23473    body.dark-theme .theme-toggle .icon-sun { display:block; }
23474    body.dark-theme .theme-toggle .icon-moon { display:none; }
23475    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
23476    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23477    .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);}
23478    .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;}
23479    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23480    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23481    .settings-modal-body{padding:14px 16px 16px;}
23482    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23483    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23484    .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;}
23485    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23486    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23487    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23488    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23489    .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;}
23490    .tz-select:focus{border-color:var(--oxide);}
23491    .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; }
23492    .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;}
23493    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23494    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23495    .hero, .panel { padding: 22px; }
23496    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23497    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23498    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23499    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23500    .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; }
23501    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23502    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23503    .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; }
23504    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23505    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23506    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23507    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23508    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23509    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23510    .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); }
23511    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23512    .delta-card-val { font-size:16px; font-weight:800; }
23513    .delta-card-val.pos { color:#1e7e34; }
23514    .delta-card-val.neg { color:var(--neg); }
23515    .delta-card-val.mod { color:#b35428; }
23516    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23517    .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; }
23518    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23519    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23520    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23521    .compare-ts { font-size:13px; color:var(--muted); }
23522    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23523    .compare-arrow { color: var(--muted); }
23524    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23525    .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; }
23526    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23527    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23528    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23529    .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; }
23530    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23531    .run-mgmt-card .action-buttons { justify-content:center; }
23532    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23533    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23534    .button, .copy-button {
23535      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;
23536    }
23537    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23538    @keyframes spin { to { transform: rotate(360deg); } }
23539    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23540    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23541    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23542    .path-item strong { display: block; margin-bottom: 6px; }
23543    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23544    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23545    .path-subitem { flex: 1; }
23546    .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); }
23547    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); }
23548    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23549    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23550    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23551    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23552    th { color: var(--muted); font-weight: 700; }
23553    tr:last-child td { border-bottom: none; }
23554    #subm-tbl col:nth-child(1){width:15%;}
23555    #subm-tbl col:nth-child(2){width:31%;}
23556    #subm-tbl col:nth-child(3){width:9%;}
23557    #subm-tbl col:nth-child(4){width:9%;}
23558    #subm-tbl col:nth-child(5){width:9%;}
23559    #subm-tbl col:nth-child(6){width:9%;}
23560    #subm-tbl col:nth-child(7){width:9%;}
23561    #subm-tbl col:nth-child(8){width:9%;}
23562    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23563    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23564    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23565    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23566    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23567    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23568    .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; }
23569    .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; }
23570    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23571    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23572    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23573    .muted { color: var(--muted); }
23574    /* Run-ID chip row (mirrors HTML report) */
23575    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23576    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23577    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23578    .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; }
23579    .run-id-chip[data-copy] { cursor:pointer; }
23580    a.run-id-chip { text-decoration:none; cursor:pointer; }
23581    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23582    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23583    .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; }
23584    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23585    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23586    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23587    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23588    a.commit-link-value { color:inherit; text-decoration:none; }
23589    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23590    .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; }
23591    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23592    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23593    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23594    .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; }
23595    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23596    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23597    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23598    /* Meta chips row */
23599    .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%; }
23600    .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; }
23601    .meta-chip:last-child { border-right:none; }
23602    .meta-chip b { color:var(--text); font-weight:700; }
23603    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23604    .site-footer a{color:var(--muted);}
23605    .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; }
23606    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23607    .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; }
23608    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23609    /* Stat chips (matches HTML report) */
23610    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23611    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23612    /* Hero stat strip: uniform grid where every card is the same width and the
23613       columns line up across both rows. JS sets the column count to ceil(n/2) so
23614       the cards always occupy exactly two rows; when the count is odd the last
23615       card spans two columns to fill the trailing cell with no empty gap. */
23616    .summary-strip-hero { align-items:stretch; }
23617    .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; }
23618    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23619    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23620    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23621    .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; }
23622    .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); }
23623    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23624    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23625    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23626    .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; }
23627    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23628    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23629    .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); }
23630    .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); }
23631    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23632    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23633    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23634    /* Submodule panel */
23635    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23636    /* Metrics tables stack */
23637    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23638    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23639    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23640    .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)); }
23641    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23642    /* Metrics table */
23643    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23644    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23645    .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; }
23646    .metrics-table thead th:not(:first-child) { text-align: right; }
23647    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23648    .metrics-table tbody tr:last-child td { border-bottom: none; }
23649    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23650    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23651    .metrics-table tbody tr:hover td { background: var(--surface-2); }
23652    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23653    .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; }
23654    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23655    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23656    .mt-val-pos { color: var(--pos); font-weight: 700; }
23657    .mt-val-neg { color: var(--neg); font-weight: 700; }
23658    .mt-val-zero { color: var(--muted); }
23659    .mt-val-mod { color: var(--oxide-2); }
23660    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23661    @media (max-width: 1180px) {
23662      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23663      .nav-project-slot, .nav-status { justify-content:flex-start; }
23664      .hero-top { flex-direction: column; }
23665      .run-mgmt-strip { flex-direction: column; }
23666    }
23667    .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;}
23668    @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));}}
23669    .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;}
23670    /* ── Result-page chart controls ─────────────────────────────────────────── */
23671    .r-chart-section{margin-bottom:24px;}
23672    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23673    .section-pair > .panel{flex-shrink:0;}
23674    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23675    .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;}
23676    .r-chart-select:focus{border-color:var(--accent);}
23677    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23678    .r-chart-container svg{display:block;width:100%;height:auto;}
23679    .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;}
23680    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23681    .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;}
23682    .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);}
23683    .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;}
23684    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23685    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23686    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23687    .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;}
23688    .r-chart-modal-close:hover{opacity:.7;}
23689    body.dark-theme .r-chart-modal{background:var(--surface);}
23690    .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;}
23691    .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);}
23692    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23693    .lang-bar-row:hover{transform:translateY(-2px);}
23694    .lang-bar-row .rchit:hover{filter:none;transform:none;}
23695    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23696    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23697    .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;}
23698    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23699    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23700    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23701    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23702    #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;}
23703    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23704    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23705    .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;}
23706    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23707    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23708    .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;}
23709    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23710    .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;}
23711    .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;}
23712    body.has-report-banner .top-nav{top:27px;}
23713    body.has-report-banner{padding-bottom:27px;}
23714  </style>
23715</head>
23716<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23717  <div class="background-watermarks" aria-hidden="true">
23718    <img src="/images/logo/logo-text.png" alt="" />
23719    <img src="/images/logo/logo-text.png" alt="" />
23720    <img src="/images/logo/logo-text.png" alt="" />
23721    <img src="/images/logo/logo-text.png" alt="" />
23722    <img src="/images/logo/logo-text.png" alt="" />
23723    <img src="/images/logo/logo-text.png" alt="" />
23724    <img src="/images/logo/logo-text.png" alt="" />
23725    <img src="/images/logo/logo-text.png" alt="" />
23726    <img src="/images/logo/logo-text.png" alt="" />
23727    <img src="/images/logo/logo-text.png" alt="" />
23728    <img src="/images/logo/logo-text.png" alt="" />
23729    <img src="/images/logo/logo-text.png" alt="" />
23730    <img src="/images/logo/logo-text.png" alt="" />
23731    <img src="/images/logo/logo-text.png" alt="" />
23732  </div>
23733  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23734  {% if let Some(banner) = report_header_footer %}
23735  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23736  {% endif %}
23737  <div class="top-nav">
23738    <div class="top-nav-inner">
23739      <a class="brand" href="/">
23740        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23741        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23742      </a>
23743      <div class="nav-project-slot">
23744        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23745      </div>
23746      <div class="nav-status">
23747        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23748        <div class="nav-dropdown">
23749          <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>
23750          <div class="nav-dropdown-menu">
23751            <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>
23752          </div>
23753        </div>
23754        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23755        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23756        <div class="nav-dropdown">
23757          <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>
23758          <div class="nav-dropdown-menu">
23759            <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>
23760          </div>
23761        </div>
23762        <div class="server-status-wrap" id="server-status-wrap">
23763          <div class="nav-pill server-online-pill" id="server-status-pill">
23764            <span class="status-dot" id="status-dot"></span>
23765            <span id="server-status-label">Server</span>
23766            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23767          </div>
23768          <div class="server-status-tip">
23769            OxideSLOC is running — accessible on your network.
23770            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23771          </div>
23772        </div>
23773        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23774          <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>
23775        </button>
23776        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23777          <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>
23778          <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>
23779        </button>
23780      </div>
23781    </div>
23782  </div>
23783
23784  <div class="page">
23785    <section class="hero">
23786      <div class="hero-top">
23787        <div>
23788          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23789            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23790            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23791            <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>
23792          </div>
23793        </div>
23794        <div class="hero-quick-actions">
23795          {% if server_mode %}
23796          <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>
23797          {% else %}
23798          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23799          {% endif %}
23800          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23801          {% if !server_mode %}
23802          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23803          {% endif %}
23804          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23805          <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>
23806        </div>
23807      </div>
23808
23809      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23810      <div class="run-id-row">
23811        <span class="run-id-chip" data-copy="{{ run_id }}">
23812          <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>
23813          <span class="run-id-chip-value">{{ run_id }}</span>
23814          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23815        </span>
23816        {% match git_commit_long %}
23817          {% when Some with (long_sha) %}
23818          {% match git_commit_url %}
23819            {% when Some with (commit_url) %}
23820            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23821              <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>
23822              <span class="run-id-chip-value">{{ long_sha }}</span>
23823              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23824            </a>
23825            {% when None %}
23826            <span class="run-id-chip" data-copy="{{ long_sha }}">
23827              <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>
23828              <span class="run-id-chip-value">{{ long_sha }}</span>
23829              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23830            </span>
23831          {% endmatch %}
23832          {% when None %}
23833          <span class="run-id-chip muted-chip">
23834            <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>
23835            <span class="run-id-chip-value">Not detected</span>
23836            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23837          </span>
23838        {% endmatch %}
23839        {% match git_branch %}
23840          {% when Some with (branch) %}
23841          {% match git_branch_url %}
23842            {% when Some with (branch_url) %}
23843            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23844              <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>
23845              <span class="run-id-chip-value">{{ branch }}</span>
23846              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23847            </a>
23848            {% when None %}
23849            <span class="run-id-chip">
23850              <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>
23851              <span class="run-id-chip-value">{{ branch }}</span>
23852              <span class="chip-tooltip">Git branch active at scan time</span>
23853            </span>
23854          {% endmatch %}
23855          {% when None %}
23856          <span class="run-id-chip muted-chip">
23857            <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>
23858            <span class="run-id-chip-value">Not detected</span>
23859            <span class="chip-tooltip">No Git branch was found for this scan</span>
23860          </span>
23861        {% endmatch %}
23862        {% match git_author %}
23863          {% when Some with (author) %}
23864          <span class="run-id-chip" data-author="{{ author }}">
23865            <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>
23866            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23867            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23868          </span>
23869          {% when None %}
23870          <span class="run-id-chip muted-chip">
23871            <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>
23872            <span class="run-id-chip-value">Not detected</span>
23873            <span class="chip-tooltip">No commit author was found for this scan</span>
23874          </span>
23875        {% endmatch %}
23876      </div>
23877
23878      <!-- Scan metadata row -->
23879      <div class="meta">
23880        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23881        <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23882        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23883        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23884        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23885      </div>
23886
23887      <!-- All summary stat chips in one unified strip (8 columns) -->
23888      <div class="summary-strip summary-strip-hero">
23889        <div class="stat-chip" data-raw="{{ physical_lines }}">
23890          <div class="stat-chip-label">Physical lines</div>
23891          <div class="stat-chip-val">{{ physical_lines }}</div>
23892          <div class="stat-chip-exact"></div>
23893          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23894        </div>
23895        <div class="stat-chip" data-raw="{{ code_lines }}">
23896          <div class="stat-chip-label">Code</div>
23897          <div class="stat-chip-val">{{ code_lines }}</div>
23898          <div class="stat-chip-exact"></div>
23899          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23900        </div>
23901        <div class="stat-chip" data-raw="{{ comment_lines }}">
23902          <div class="stat-chip-label">Comments</div>
23903          <div class="stat-chip-val">{{ comment_lines }}</div>
23904          <div class="stat-chip-exact"></div>
23905          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23906        </div>
23907        <div class="stat-chip" data-raw="{{ blank_lines }}">
23908          <div class="stat-chip-label">Blank</div>
23909          <div class="stat-chip-val">{{ blank_lines }}</div>
23910          <div class="stat-chip-exact"></div>
23911          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23912        </div>
23913        <div class="stat-chip" data-raw="{{ mixed_lines }}">
23914          <div class="stat-chip-label">Mixed separate</div>
23915          <div class="stat-chip-val">{{ mixed_lines }}</div>
23916          <div class="stat-chip-exact"></div>
23917          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23918        </div>
23919        <div class="stat-chip" data-raw="{{ functions }}">
23920          <div class="stat-chip-label">Functions</div>
23921          <div class="stat-chip-val">{{ functions }}</div>
23922          <div class="stat-chip-exact"></div>
23923          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23924        </div>
23925        <div class="stat-chip" data-raw="{{ classes }}">
23926          <div class="stat-chip-label">Classes / Types</div>
23927          <div class="stat-chip-val">{{ classes }}</div>
23928          <div class="stat-chip-exact"></div>
23929          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23930        </div>
23931        <div class="stat-chip" data-raw="{{ variables }}">
23932          <div class="stat-chip-label">Variables</div>
23933          <div class="stat-chip-val">{{ variables }}</div>
23934          <div class="stat-chip-exact"></div>
23935          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23936        </div>
23937        <div class="stat-chip" data-raw="{{ imports }}">
23938          <div class="stat-chip-label">Imports</div>
23939          <div class="stat-chip-val">{{ imports }}</div>
23940          <div class="stat-chip-exact"></div>
23941          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23942        </div>
23943        <div class="stat-chip" data-raw="{{ test_count }}">
23944          <div class="stat-chip-label">Tests</div>
23945          <div class="stat-chip-val">{{ test_count }}</div>
23946          <div class="stat-chip-exact"></div>
23947          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23948        </div>
23949        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23950          <div class="stat-chip-label">Code density</div>
23951          <div class="stat-chip-val stat-chip-density-val">—</div>
23952          <div class="stat-chip-exact"></div>
23953          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23954        </div>
23955        <div class="stat-chip" data-raw="{{ files_analyzed }}">
23956          <div class="stat-chip-label">Files analyzed</div>
23957          <div class="stat-chip-val">{{ files_analyzed }}</div>
23958          <div class="stat-chip-exact"></div>
23959          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23960        </div>
23961        {% if cyclomatic_complexity > 0 %}
23962        <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 %}>
23963          <div class="stat-chip-label">Complexity score</div>
23964          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23965          <div class="stat-chip-exact"></div>
23966          <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>
23967        </div>
23968        {% endif %}
23969        {% if let Some(ls) = lsloc %}
23970        <div class="stat-chip" data-raw="{{ ls }}">
23971          <div class="stat-chip-label">Logical SLOC</div>
23972          <div class="stat-chip-val">{{ ls }}</div>
23973          <div class="stat-chip-exact"></div>
23974          <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>
23975        </div>
23976        {% endif %}
23977        {% if uloc > 0 %}
23978        <div class="stat-chip" data-raw="{{ uloc }}">
23979          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23980          <div class="stat-chip-val">{{ uloc }}</div>
23981          <div class="stat-chip-exact"></div>
23982          <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>
23983        </div>
23984        {% endif %}
23985        {% if uloc > 0 && dryness_pct_str != "" %}
23986        <div class="stat-chip">
23987          <div class="stat-chip-label">DRYness</div>
23988          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23989          <div class="stat-chip-exact"></div>
23990          <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>
23991        </div>
23992        {% endif %}
23993        {% if duplicate_group_count > 0 %}
23994        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23995          <div class="stat-chip-label">Duplicate groups</div>
23996          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23997          <div class="stat-chip-exact"></div>
23998          <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>
23999        </div>
24000        {% endif %}
24001        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
24002             odd, so the strip always forms exactly two full rows with every column
24003             aligned and every card the same width (no oversized card, no gap). -->
24004        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
24005          <div class="stat-chip-label">Assertions</div>
24006          <div class="stat-chip-val">{{ test_assertion_count }}</div>
24007          <div class="stat-chip-exact"></div>
24008          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
24009        </div>
24010      </div>
24011
24012      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
24013      <div class="compare-banner">
24014        <div class="compare-banner-body">
24015          <div class="compare-banner-top">
24016          <div class="compare-banner-meta">
24017            <span class="compare-label">Previous scan</span>
24018            <span class="compare-ts">{{ prev_ts }}</span>
24019            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
24020            {% if let Some(prev_code) = prev_run_code_lines %}
24021            <div class="compare-banner-stats" style="margin-top:4px;">
24022              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
24023              <span class="compare-arrow">→</span>
24024              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
24025              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
24026              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
24027            </div>
24028            {% endif %}
24029          </div>
24030          {% if delta_lines_added.is_some() %}
24031          <div class="delta-cards-inline">
24032            <div class="delta-card-inline">
24033              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
24034              <div class="delta-card-lbl">lines added</div>
24035              <div class="delta-card-tip">Code lines added since the previous scan</div>
24036            </div>
24037            <div class="delta-card-inline">
24038              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
24039              <div class="delta-card-lbl">lines removed</div>
24040              <div class="delta-card-tip">Code lines removed since the previous scan</div>
24041            </div>
24042            <div class="delta-card-inline">
24043              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
24044              <div class="delta-card-lbl">unmodified lines</div>
24045              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
24046            </div>
24047            <div class="delta-card-inline">
24048              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
24049              <div class="delta-card-lbl">files modified</div>
24050              <div class="delta-card-tip">Files with at least one line changed</div>
24051            </div>
24052            <div class="delta-card-inline">
24053              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
24054              <div class="delta-card-lbl">files added</div>
24055              <div class="delta-card-tip">New files added since the previous scan</div>
24056            </div>
24057            <div class="delta-card-inline">
24058              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
24059              <div class="delta-card-lbl">files removed</div>
24060              <div class="delta-card-tip">Files deleted since the previous scan</div>
24061            </div>
24062            <div class="delta-card-inline">
24063              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
24064              <div class="delta-card-lbl">files unchanged</div>
24065              <div class="delta-card-tip">Files with no changes since the previous scan</div>
24066            </div>
24067            <div class="delta-card-inline">
24068              <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
24069              <div class="delta-card-lbl">files total</div>
24070              <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
24071            </div>
24072          </div>
24073          {% else %}
24074          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
24075            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
24076          </p>
24077          {% endif %}
24078          </div>
24079          <div class="compare-banner-actions">
24080            <div class="compare-banner-actions-left">
24081              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
24082              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
24083            </div>
24084            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
24085          </div>
24086        </div>
24087      </div>
24088      {% endif %}{% endif %}
24089
24090      <div class="action-grid">
24091        <div class="action-card">
24092          <h3>HTML report</h3>
24093          <div class="action-buttons">
24094            {% match html_url %}
24095              {% when Some with (url) %}
24096                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
24097              {% when None %}{% endmatch %}
24098            {% match html_download_url %}
24099              {% when Some with (url) %}
24100                <a class="button secondary" href="{{ url }}">Download HTML</a>
24101              {% when None %}{% endmatch %}
24102            {% match html_path %}
24103              {% when Some with (_path) %}{% when None %}{% endmatch %}
24104            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
24105          </div>
24106        </div>
24107        <div class="action-card">
24108          <h3>PDF report</h3>
24109          <div class="action-buttons">
24110            {% match pdf_url %}
24111              {% when Some with (url) %}
24112                {% if pdf_generating %}
24113                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
24114                    <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>
24115                    Generating PDF…
24116                  </button>
24117                {% else %}
24118                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
24119                {% endif %}
24120              {% when None %}
24121                {% match html_url %}
24122                  {% when Some with (_hurl) %}
24123                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
24124                    <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>
24125                  {% when None %}
24126                    <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;">
24127                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
24128                    </p>
24129                {% endmatch %}
24130            {% endmatch %}
24131            {% match pdf_download_url %}
24132              {% when Some with (url) %}
24133                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
24134              {% when None %}{% endmatch %}
24135            {% match pdf_url %}
24136              {% when Some with (_) %}
24137                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
24138              {% when None %}{% endmatch %}
24139          </div>
24140        </div>
24141        <div class="action-card">
24142          <h3>JSON result</h3>
24143          <div class="action-buttons">
24144            {% match json_url %}
24145              {% when Some with (url) %}
24146                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
24147              {% when None %}{% endmatch %}
24148            {% match json_download_url %}
24149              {% when Some with (url) %}
24150                <a class="button secondary" href="{{ url }}">Download JSON</a>
24151              {% when None %}{% endmatch %}
24152            {% match json_path %}
24153              {% when Some with (_path) %}
24154                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
24155              {% when None %}
24156                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
24157              {% endmatch %}
24158          </div>
24159        </div>
24160        <div class="action-card">
24161          <h3>Scan config</h3>
24162          <div class="action-buttons">
24163            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
24164            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
24165            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
24166          </div>
24167        </div>
24168        {% if confluence_configured %}
24169        <div class="action-card" id="confluenceCard">
24170          <h3>Confluence</h3>
24171          <div class="action-buttons">
24172            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
24173            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
24174          </div>
24175          <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>
24176        </div>
24177        {% endif %}
24178      </div>
24179      {% if confluence_configured %}
24180      <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;">
24181        <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);">
24182          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
24183          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
24184          <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;">
24185          <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>
24186          <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;">
24187          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
24188          <div style="display:flex;gap:10px;justify-content:flex-end;">
24189            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
24190            <button class="button" id="confSubmitBtn" type="button">Post</button>
24191          </div>
24192        </div>
24193      </div>
24194      {% endif %}
24195      <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;">
24196        <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);">
24197          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
24198          <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>
24199          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
24200          <div style="display:flex;gap:18px;justify-content:flex-end;">
24201            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
24202            <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>
24203          </div>
24204        </div>
24205      </div>
24206      {% if !submodule_rows.is_empty() %}
24207      <div class="submodule-panel">
24208        <div class="toolbar-row">
24209          <div>
24210            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
24211            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
24212          </div>
24213          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
24214        </div>
24215        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
24216        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
24217          <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>
24218          <thead>
24219            <tr>
24220              <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>
24221              <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>
24222              <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>
24223              <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>
24224              <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>
24225              <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>
24226              <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>
24227              <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>
24228            </tr>
24229          </thead>
24230          <tbody>
24231            {% for row in submodule_rows %}
24232            <tr>
24233              <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>
24234              <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>
24235              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24236              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24237              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24238              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24239              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24240              <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>
24241            </tr>
24242            {% endfor %}
24243          </tbody>
24244        </table>
24245        </div>
24246      </div>
24247      {% endif %}
24248
24249      <div class="metrics-tables-stack">
24250
24251        <div class="metrics-table-wrap">
24252          <div class="metrics-table-title">Files</div>
24253          <table class="metrics-table">
24254            <thead>
24255              <tr>
24256                <th>Metric</th>
24257                <th>This Run</th>
24258                <th>Previous</th>
24259                <th>Change</th>
24260              </tr>
24261            </thead>
24262            <tbody>
24263              <tr>
24264                <td>Files analyzed</td>
24265                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24266                <td>{{ prev_fa_str|commas }}</td>
24267                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24268              </tr>
24269              <tr>
24270                <td>Files skipped</td>
24271                <td>{{ files_skipped|commas }}</td>
24272                <td>{{ prev_fs_str|commas }}</td>
24273                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24274              </tr>
24275              <tr>
24276                <td>Files modified</td>
24277                <td class="mt-val-na">—</td>
24278                <td class="mt-val-na">—</td>
24279                <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>
24280              </tr>
24281              <tr>
24282                <td>Files unchanged</td>
24283                <td class="mt-val-na">—</td>
24284                <td class="mt-val-na">—</td>
24285                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24286              </tr>
24287              <tr>
24288                <td>Files total</td>
24289                <td class="mt-val-na">—</td>
24290                <td class="mt-val-na">—</td>
24291                <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24292              </tr>
24293            </tbody>
24294          </table>
24295        </div>
24296
24297        <div class="metrics-table-wrap">
24298          <div class="metrics-table-title">Line Counts</div>
24299          <table class="metrics-table">
24300            <thead>
24301              <tr>
24302                <th>Metric</th>
24303                <th>This Run</th>
24304                <th>Previous</th>
24305                <th>Change</th>
24306              </tr>
24307            </thead>
24308            <tbody>
24309              <tr>
24310                <td>Physical lines</td>
24311                <td class="mt-val-large">{{ physical_lines|commas }}</td>
24312                <td>{{ prev_pl_str|commas }}</td>
24313                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24314              </tr>
24315              <tr>
24316                <td>Code lines</td>
24317                <td class="mt-val-large">{{ code_lines|commas }}</td>
24318                <td>{{ prev_cl_str|commas }}</td>
24319                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24320              </tr>
24321              <tr>
24322                <td>Comment lines</td>
24323                <td>{{ comment_lines|commas }}</td>
24324                <td>{{ prev_cml_str|commas }}</td>
24325                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24326              </tr>
24327              <tr>
24328                <td>Blank lines</td>
24329                <td>{{ blank_lines|commas }}</td>
24330                <td>{{ prev_bl_str|commas }}</td>
24331                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24332              </tr>
24333              <tr>
24334                <td>Mixed (separate)</td>
24335                <td>{{ mixed_lines|commas }}</td>
24336                <td class="mt-val-na">—</td>
24337                <td class="mt-val-na">—</td>
24338              </tr>
24339            </tbody>
24340          </table>
24341        </div>
24342
24343        <div class="metrics-tables-lower">
24344          <div class="metrics-table-wrap">
24345            <div class="metrics-table-title">Code Structure</div>
24346            <table class="metrics-table">
24347              <thead>
24348                <tr>
24349                  <th>Metric</th>
24350                  <th>This Run</th>
24351                </tr>
24352              </thead>
24353              <tbody>
24354                <tr>
24355                  <td>Functions</td>
24356                  <td>{{ functions|commas }}</td>
24357                </tr>
24358                <tr>
24359                  <td>Classes / Types</td>
24360                  <td>{{ classes|commas }}</td>
24361                </tr>
24362                <tr>
24363                  <td>Variables</td>
24364                  <td>{{ variables|commas }}</td>
24365                </tr>
24366                <tr>
24367                  <td>Imports</td>
24368                  <td>{{ imports|commas }}</td>
24369                </tr>
24370              </tbody>
24371            </table>
24372          </div>
24373
24374          <div class="metrics-table-wrap">
24375            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24376            <table class="metrics-table">
24377              <thead>
24378                <tr>
24379                  <th>Metric</th>
24380                  <th>Change</th>
24381                </tr>
24382              </thead>
24383              <tbody>
24384                <tr>
24385                  <td>Lines added</td>
24386                  <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>
24387                </tr>
24388                <tr>
24389                  <td>Lines removed</td>
24390                  <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>
24391                </tr>
24392                <tr>
24393                  <td>Lines modified (net)</td>
24394                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24395                </tr>
24396                <tr>
24397                  <td>Lines unmodified</td>
24398                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24399                </tr>
24400              </tbody>
24401            </table>
24402          </div>
24403        </div>
24404
24405      </div>
24406
24407      <div class="path-list">
24408        <div class="path-item">
24409          <div class="path-item-label">Project path</div>
24410          {% 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 %}
24411        </div>
24412        <div class="path-item">
24413          <div class="path-item-label">Git branch</div>
24414          {% if let Some(branch) = git_branch %}
24415          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24416          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24417          {% else %}
24418          <code style="color:var(--muted)">—</code>
24419          {% endif %}
24420        </div>
24421        <div class="path-item">
24422          <div class="path-item-label">Output folder</div>
24423          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24424        </div>
24425        <div class="path-item">
24426          <div class="path-item-label">Run ID</div>
24427          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24428            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24429            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24430          </div>
24431        </div>
24432      </div>
24433    </section>
24434
24435    {% if has_cocomo %}
24436    <div class="cocomo-box" style="margin-top:24px;">
24437      <div class="cocomo-box-head">
24438        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
24439        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24440          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24441          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24442        </span>
24443      </div>
24444      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24445        <div class="stat-chip">
24446          <div class="stat-chip-label">Person-months</div>
24447          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24448          <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>
24449        </div>
24450        <div class="stat-chip">
24451          <div class="stat-chip-label">Schedule (months)</div>
24452          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24453          <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>
24454        </div>
24455        <div class="stat-chip">
24456          <div class="stat-chip-label">Avg. Team Size</div>
24457          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24458          <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>
24459        </div>
24460        <div class="stat-chip">
24461          <div class="stat-chip-label">Input KSLOC</div>
24462          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24463          <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>
24464        </div>
24465      </div>
24466      <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>
24467    </div>
24468    {% endif %}
24469
24470    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24471    <div class="cocomo-box" style="margin-top:24px;">
24472      <div class="cocomo-box-head">
24473        <span class="cocomo-box-title">Tests &amp; Coverage</span>
24474        {% if has_coverage_data %}
24475        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24476          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24477        </span>
24478        {% endif %}
24479      </div>
24480      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24481        <div class="stat-chip">
24482          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24483          <div class="stat-chip-label">Test Functions</div>
24484          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24485        </div>
24486        <div class="stat-chip">
24487          {% if has_coverage_data %}
24488          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24489          {% else %}
24490          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24491          {% endif %}
24492          <div class="stat-chip-label">Line Coverage</div>
24493          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24494        </div>
24495        <div class="stat-chip">
24496          {% if !cov_fn_pct.is_empty() %}
24497          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24498          {% else %}
24499          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24500          {% endif %}
24501          <div class="stat-chip-label">Fn Coverage</div>
24502          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24503        </div>
24504        <div class="stat-chip">
24505          {% if !cov_branch_pct.is_empty() %}
24506          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24507          {% else %}
24508          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24509          {% endif %}
24510          <div class="stat-chip-label">Branch Coverage</div>
24511          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24512        </div>
24513      </div>
24514      {% if has_coverage_data %}
24515      <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>
24516      {% else %}
24517      <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>
24518      {% endif %}
24519    </div>
24520
24521    <div class="section-pair">
24522    <section class="panel">
24523        <div class="toolbar-row">
24524          <div>
24525            <h2>Language Breakdown</h2>
24526            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24527          </div>
24528          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24529        </div>
24530        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24531    </section>
24532
24533    <section class="panel r-chart-section">
24534      <div class="toolbar-row" style="margin-bottom:16px;">
24535        <div>
24536          <h2>Visualizations</h2>
24537          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24538        </div>
24539      </div>
24540
24541      <div class="r-viz-grid">
24542        <div class="r-viz-card">
24543          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24544            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24545            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24546          </div>
24547          <div class="r-chart-tab-bar">
24548            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24549            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24550          </div>
24551          <div class="r-chart-container" id="r-composition-chart"></div>
24552        </div>
24553        <div class="r-viz-card">
24554          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24555            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24556            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24557          </div>
24558          <div class="r-chart-container" id="r-scatter-chart"></div>
24559        </div>
24560        {% if has_semantic_data %}
24561        <div class="r-viz-card">
24562          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24563            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24564            <select class="r-chart-select" id="r-semantic-metric">
24565              <option value="functions">Functions</option>
24566              <option value="classes">Classes</option>
24567              <option value="variables">Variables</option>
24568              <option value="imports">Imports</option>
24569              <option value="tests">Tests</option>
24570            </select>
24571            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24572          </div>
24573          <div class="r-chart-container" id="r-semantic-chart"></div>
24574        </div>
24575        {% endif %}
24576        <div class="r-viz-card">
24577          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24578            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24579            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24580          </div>
24581          <div class="r-chart-container" id="r-density-chart"></div>
24582        </div>
24583        <div class="r-viz-card">
24584          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24585            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24586            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24587          </div>
24588          <div class="r-chart-container" id="r-avglines-chart"></div>
24589        </div>
24590        <div class="r-viz-card">
24591          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24592            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24593            <select class="r-chart-select" id="r-sub-metric">
24594              <option value="code">Code Lines</option>
24595              <option value="comment">Comments</option>
24596              <option value="blank">Blank Lines</option>
24597              <option value="physical">Physical Lines</option>
24598              <option value="files">Files</option>
24599            </select>
24600            <select class="r-chart-select" id="r-sub-sort">
24601              <option value="desc">Value ↓</option>
24602              <option value="asc">Value ↑</option>
24603              <option value="name">Name A→Z</option>
24604            </select>
24605            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24606          </div>
24607          <div class="r-chart-container" id="r-submodule-chart"></div>
24608        </div>
24609      </div>
24610
24611    </section>
24612    </div>
24613
24614  </div>
24615
24616  <div id="r-tt" aria-hidden="true"></div>
24617
24618  <script nonce="{{ csp_nonce }}">
24619    (function () {
24620      var body = document.body;
24621      var themeToggle = document.getElementById('theme-toggle');
24622      var storageKey = 'oxide-sloc-theme';
24623
24624      function applyTheme(theme) {
24625        body.classList.toggle('dark-theme', theme === 'dark');
24626      }
24627
24628      function loadSavedTheme() {
24629        try {
24630          var saved = localStorage.getItem(storageKey);
24631          if (saved === 'dark' || saved === 'light') {
24632            applyTheme(saved);
24633          }
24634        } catch (e) {}
24635      }
24636
24637      if (themeToggle) {
24638        themeToggle.addEventListener('click', function () {
24639          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24640          applyTheme(nextTheme);
24641          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24642        });
24643      }
24644
24645      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24646        button.addEventListener('click', function () {
24647          var value = button.getAttribute('data-copy-value') || '';
24648          if (!value) return;
24649          var originalText = button.textContent;
24650          function flashSuccess() {
24651            button.textContent = 'Copied!';
24652            setTimeout(function () { button.textContent = originalText; }, 1800);
24653          }
24654          function flashFail() {
24655            button.textContent = 'Copy failed';
24656            setTimeout(function () { button.textContent = originalText; }, 2000);
24657          }
24658          if (navigator.clipboard && navigator.clipboard.writeText) {
24659            navigator.clipboard.writeText(value).then(flashSuccess, function () {
24660              fallbackCopy(value, flashSuccess, flashFail);
24661            });
24662          } else {
24663            fallbackCopy(value, flashSuccess, flashFail);
24664          }
24665        });
24666      });
24667      function fallbackCopy(text, onSuccess, onFail) {
24668        try {
24669          var ta = document.createElement('textarea');
24670          ta.value = text;
24671          ta.style.position = 'fixed';
24672          ta.style.top = '-9999px';
24673          ta.style.left = '-9999px';
24674          document.body.appendChild(ta);
24675          ta.focus();
24676          ta.select();
24677          var ok = document.execCommand('copy');
24678          document.body.removeChild(ta);
24679          if (ok) { onSuccess(); } else { onFail(); }
24680        } catch (e) { onFail(); }
24681      }
24682
24683      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24684        btn.addEventListener('click', function () {
24685          var folder = btn.getAttribute('data-folder') || '';
24686          if (!folder) return;
24687          var orig = btn.textContent;
24688          fetch('/open-path?path=' + encodeURIComponent(folder))
24689            .then(function (r) { return r.json(); })
24690            .then(function (d) {
24691              if (d && d.server_mode_disabled) {
24692                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24693              } else if (d && d.ok) {
24694                btn.textContent = 'Opened!';
24695                setTimeout(function () { btn.textContent = orig; }, 1800);
24696              }
24697            })
24698            .catch(function () {
24699              btn.textContent = 'Failed';
24700              setTimeout(function () { btn.textContent = orig; }, 2000);
24701            });
24702        });
24703      });
24704
24705      loadSavedTheme();
24706
24707      // ── Compact number formatting for stat chips ──────────────────────────
24708      (function(){
24709        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();}
24710        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24711          var raw=parseInt(chip.getAttribute('data-raw'),10);
24712          if(isNaN(raw))return;
24713          var valEl=chip.querySelector('.stat-chip-val');
24714          if(valEl)valEl.textContent=fmt(raw);
24715          var exactEl=chip.querySelector('.stat-chip-exact');
24716          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24717        });
24718        // Code density chip
24719        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24720          var code=parseInt(chip.getAttribute('data-code'),10);
24721          var phys=parseInt(chip.getAttribute('data-physical'),10);
24722          if(isNaN(code)||isNaN(phys)||phys===0)return;
24723          var pct=(code/phys*100).toFixed(1)+'%';
24724          var valEl=chip.querySelector('.stat-chip-val');
24725          if(valEl)valEl.textContent=pct;
24726        });
24727        // Populate author handle from data-author attribute
24728        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24729          var author=chip.getAttribute('data-author');
24730          var el=chip.querySelector('.author-handle');
24731          if(el)el.textContent='/'+author.replace(/\s+/g,'');
24732        });
24733        // Click-to-copy on run-id-chip elements
24734        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24735          chip.addEventListener('click',function(){
24736            var val=chip.getAttribute('data-copy');
24737            if(!val)return;
24738            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24739            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);}
24740            chip.classList.add('chip-copied-flash');
24741            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24742          });
24743        });
24744        // Format delta card values with data-raw using comma-separated full numbers
24745        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24746          var raw=parseInt(card.getAttribute('data-raw'),10);
24747          if(isNaN(raw))return;
24748          var valEl=card.querySelector('.delta-card-val');
24749          if(valEl)valEl.textContent=raw.toLocaleString();
24750        });
24751        // Format code-before / code-now numbers in the compare banner stats line
24752        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24753          var raw=parseInt(el.getAttribute('data-raw'),10);
24754          if(!isNaN(raw))el.textContent=raw.toLocaleString();
24755        });
24756      })();
24757
24758      // ── Shared tooltip for all result-page charts ─────────────────────────
24759      var rTT=(function(){
24760        var el=document.getElementById('r-tt');
24761        if(!el)return{s:function(){},h:function(){},m:function(){}};
24762        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24763        function hide(){el.style.display='none';}
24764        function move(e){
24765          var x=e.clientX+16,y=e.clientY-12;
24766          var r=el.getBoundingClientRect();
24767          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24768          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24769          el.style.left=x+'px';el.style.top=y+'px';
24770        }
24771        return{s:show,h:hide,m:move};
24772      })();
24773      window.rTT=rTT;
24774
24775      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24776      (function(){
24777        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24778        document.addEventListener('mouseover',function(e){
24779          var t=e.target;
24780          while(t&&t.getAttribute){
24781            var l=t.getAttribute('data-ttl');
24782            if(l!==null){
24783              var v=t.getAttribute('data-ttv')||'';
24784              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24785              return;
24786            }
24787            t=t.parentNode;
24788          }
24789        });
24790        document.addEventListener('mouseout',function(e){
24791          var t=e.target;
24792          while(t&&t.getAttribute){
24793            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24794            t=t.parentNode;
24795          }
24796        });
24797        document.addEventListener('mousemove',function(e){
24798          var el=document.getElementById('r-tt');
24799          if(el&&el.style.display!=='none')rTT.m(e);
24800        });
24801        window.addEventListener('blur',function(){rTT.h();});
24802        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24803      })();
24804
24805      // ── Language overview charts ───────────────────────────────────────────
24806      (function(){
24807        var D={{ lang_chart_json|safe }};
24808        if(!D||!D.length)return;
24809        var el=document.getElementById('result-lang-charts');
24810        if(!el)return;
24811        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24812        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24813        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24814        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();}
24815        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24816        function px(n){return Math.round(n);}
24817        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+'"';}
24818        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24819        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24820        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24821        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;}
24822        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24823
24824        // Donut chart — height matches the stacked-bar chart so both panels align
24825        var rHb_d=28;
24826        var DH=Math.max(220,D.length*rHb_d+32);
24827        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24828        var legX=208,DW=395;
24829        var legCount=D.length;
24830        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24831        var legYStart=Math.round((DH-legCount*legSpacing)/2);
24832        var ds='<svg id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24833        // One shared transition on every donut element so slices, leader lines,
24834        // outside labels, % labels and the legend all animate together as a single
24835        // picture when a language is hovered. Slices scale from the donut centre.
24836        ds+='<style>#dnt-svg path,#dnt-svg circle,#dnt-svg line,#dnt-svg text,#dnt-svg g{transition:opacity .22s ease,filter .22s ease,transform .22s ease,stroke-width .22s ease;}#dnt-svg path,#dnt-svg circle{transform-origin:'+cx+'px '+cy+'px;}</style>';
24837        if(D.length===1){
24838          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24839          ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' data-lang="'+esc(D[0].lang)+'" cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'"/>';
24840        } else {
24841          var smalls=[];
24842          var ang=-Math.PI/2;
24843          D.forEach(function(d,i){
24844            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24845            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24846            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24847            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24848            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24849            var pct=Math.round(d.code/tot*100);
24850            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"/>';
24851            if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text data-lang="'+esc(d.lang)+'" 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]});}
24852            ang+=sw;
24853          });
24854          // Small slices (<5%) get outside labels positioned near each slice's own
24855          // angular position (a slice on the left gets its label/leader on the left),
24856          // then nudged apart horizontally so text never overlaps. Leader lines point
24857          // from each slice to its label. Horizontal text keeps long names legible;
24858          // the whole SVG scales up in Full View so these stay readable there too.
24859          if(smalls.length){
24860            smalls.sort(function(a,b){return a.mAng-b.mAng;});
24861            var sPad=6,sRowY=11;
24862            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)));});
24863            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;}
24864            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24865            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24866            smalls.forEach(function(sm){
24867              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24868              ds+='<line data-lang="'+esc(sm.lang)+'" 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;"/>';
24869              ds+='<text data-lang="'+esc(sm.lang)+'" x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="cursor:pointer;">'+esc(sm.txt)+'</text>';
24870            });
24871          }
24872        }
24873        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24874        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24875        D.forEach(function(d,i){
24876          var ly=legYStart+i*legSpacing;
24877          var pctL=Math.round(d.code/tot*100);
24878          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24879          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24880          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24881          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24882          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24883          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24884          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>';
24885          ds+='</g>';
24886        });
24887        ds+='</svg>';
24888
24889        // Horizontal stacked-bar chart — fills container width
24890        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24891        var LW=108,BW=260,svgW=LW+BW+68;
24892        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24893        var barBH=Math.min(32,Math.round(barRhb*0.7));
24894        var SH=DH;
24895        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24896        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">';
24897        D.forEach(function(d,i){
24898          var y=barTopPad+i*barRhb,x=LW;
24899          var phys=d.physical||d.code+d.comments+d.blanks;
24900          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24901          var lmid=y+barBH/2+4;
24902          // Combined breakdown shown when hovering the row, the language name, or the
24903          // total at the bar end (\n becomes a line break in the tooltip).
24904          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24905          bs+='<g class="lang-bar-row">';
24906          // Hit area ends just past the total label so empty space to the right of the
24907          // bar does not trigger the tooltip — only the name, bar and total are hot.
24908          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24909          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24910          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>';
24911          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;}
24912          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;}
24913          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>';}
24914          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>';
24915          bs+='</g>';
24916        });
24917        var ly=SH-14;
24918        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24919        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24920        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24921        var totAll=totC+totCm+totBl||1;
24922        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24923        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24924        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24925        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24926        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24927        bs+='<g data-kind="code" style="cursor:pointer;">'
24928          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24929          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24930          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24931          +'</g>';
24932        bs+='<g data-kind="comment" style="cursor:pointer;">'
24933          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24934          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24935          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24936          +'</g>';
24937        bs+='<g data-kind="blank" style="cursor:pointer;">'
24938          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24939          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24940          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24941          +'</g>';
24942        bs+='</svg>';
24943        el.innerHTML='<div class="r-lang-overview">'+
24944          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24945          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24946        '</div>';
24947        function wireDonutLegend(svg){
24948          if(!svg)return;
24949          // Every donut element carries data-lang: slices (path/circle), leader lines,
24950          // outside labels + % labels (text) and legend rows (g). Hovering any one of
24951          // them emphasises that language across all of them and fades the rest, so the
24952          // slice, its leader line, its label and its legend row move as one picture.
24953          var items=svg.querySelectorAll('[data-lang]');
24954          function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24955            var tag=el.tagName.toLowerCase();
24956            if(tag==='path'||tag==='circle'){
24957              if(st===1){el.style.opacity='1';el.style.filter='brightness(1.15) drop-shadow(0 3px 9px rgba(0,0,0,.28))';el.style.transform='scale(1.06)';}
24958              else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24959              else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24960            }else if(tag==='line'){
24961              if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24962              else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24963              else{el.style.opacity='';el.style.strokeWidth='';}
24964            }else if(tag==='text'){
24965              if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24966              else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24967              else{el.style.opacity='';el.style.fontWeight='';}
24968            }else{ // legend group
24969              if(st===1){el.style.opacity='1';}
24970              else if(st===-1){el.style.opacity='0.4';}
24971              else{el.style.opacity='';}
24972            }
24973          }
24974          function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24975          function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24976          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();});
24977          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();});
24978          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24979        }
24980        function wireMixLegend(svg){
24981          if(!svg)return;
24982          var legGs=svg.querySelectorAll('g[data-kind]');
24983          var allRects=svg.querySelectorAll('rect[data-kind]');
24984          if(!legGs.length)return;
24985          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';}}
24986          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='';}}
24987          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]);}
24988        }
24989        wireDonutLegend(el.querySelector('svg'));
24990        wireMixLegend(el.querySelectorAll('svg')[1]);
24991
24992        // ── Language breakdown Full View expand ─────────────────────────────────
24993        var langOvBtn=document.getElementById('result-lang-overview-expand');
24994        if(langOvBtn){langOvBtn.addEventListener('click',function(){
24995          var src=document.getElementById('result-lang-charts');
24996          if(!src)return;
24997          var overlay=document.createElement('div');
24998          overlay.className='r-chart-modal-overlay';
24999          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>';
25000          document.body.appendChild(overlay);
25001          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25002          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25003          var wrap=document.getElementById('result-lang-overview-modal-wrap');
25004          if(wrap){
25005            wrap.innerHTML=src.innerHTML;
25006            var svgs=wrap.querySelectorAll('svg');
25007            for(var i=0;i<svgs.length;i++){
25008              svgs[i].removeAttribute('width');
25009              svgs[i].removeAttribute('height');
25010              svgs[i].style.cssText='display:block;width:100%;height:auto;';
25011            }
25012            var ov=wrap.querySelector('.r-lang-overview');
25013            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
25014            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
25015            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
25016            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
25017            wireDonutLegend(wrap.querySelector('svg'));
25018            wireMixLegend(wrap.querySelectorAll('svg')[1]);
25019            requestAnimationFrame(function(){
25020              var ss=wrap.querySelectorAll('svg');
25021              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%;';}}
25022            });
25023          }
25024        });}
25025      })();
25026
25027      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
25028      (function(){
25029        var LANG_D={{ lang_chart_json|safe }};
25030        var SCAT_D={{ scatter_chart_json|safe }};
25031        var SEM_D={{ semantic_chart_json|safe }};
25032        var SUB_D={{ submodule_chart_json|safe }};
25033        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
25034        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
25035        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();}
25036        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
25037        function px(n){return Math.round(n);}
25038        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+'"';}
25039        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
25040        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
25041        // rather than disappear; the SVG scales up in Full View).
25042        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;}
25043
25044        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
25045        function renderCompositionInEl(el,mode,shOvr){
25046          if(!el||!LANG_D||!LANG_D.length)return;
25047          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
25048          var LW=110,SH=shOvr||300;
25049          var svgW=Math.max(320,el.offsetWidth||480);
25050          var BW=Math.max(120,svgW-LW-80);
25051          var legendH=24,topPad=4;
25052          var n=LANG_D.length||1;
25053          var rowTotal=Math.floor((SH-legendH-topPad)/n);
25054          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25055          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">';
25056          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
25057          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
25058          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
25059          var totAll2=totC2+totCm2+totBl2||1;
25060          if(mode==='pct'){
25061            LANG_D.forEach(function(d,i){
25062              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
25063              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
25064              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25065              var lmid=y+Math.floor(bH/2)+4;
25066              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
25067              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>';
25068              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;}
25069              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;}
25070              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>';}
25071              var pct=Math.round((d.code||0)/tot2*100);
25072              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>';
25073            });
25074          } else {
25075            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
25076            LANG_D.forEach(function(d,i){
25077              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
25078              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25079              var lmid=y+Math.floor(bH/2)+4;
25080              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));
25081              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>';
25082              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;}
25083              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;}
25084              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>';}
25085              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>';
25086            });
25087          }
25088          var ly=SH-legendH+4;
25089          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
25090          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
25091          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
25092          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
25093          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
25094          s+='<g data-kind="code" style="cursor:pointer;">'
25095            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
25096            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
25097            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
25098            +'</g>';
25099          s+='<g data-kind="comment" style="cursor:pointer;">'
25100            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
25101            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
25102            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
25103            +'</g>';
25104          s+='<g data-kind="blank" style="cursor:pointer;">'
25105            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
25106            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
25107            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
25108            +'</g>';
25109          s+='</svg>';
25110          el.innerHTML=s;
25111          wireMixLegendEl(el);
25112        }
25113        function wireMixLegendEl(container){
25114          var svg=container&&container.querySelector('svg');
25115          if(!svg)return;
25116          var legGs=svg.querySelectorAll('g[data-kind]');
25117          var allRects=svg.querySelectorAll('rect[data-kind]');
25118          if(!legGs.length)return;
25119          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';}}
25120          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='';}}
25121          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]);}
25122        }
25123        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
25124        renderComposition('abs');
25125        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
25126          btn.addEventListener('click',function(){
25127            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
25128            btn.classList.add('active');
25129            renderComposition(btn.getAttribute('data-rcomp'));
25130          });
25131        });
25132
25133        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
25134        function wireScatterLegend(container){
25135          var svg=container&&container.querySelector('svg');
25136          if(!svg)return;
25137          var legGs=svg.querySelectorAll('g[data-lang]');
25138          var circs=svg.querySelectorAll('circle[data-lang]');
25139          var labs=svg.querySelectorAll('text[data-lang]');
25140          if(!legGs.length)return;
25141          // Raise an element to the top of its parent so the hovered bubble and its
25142          // name/number labels sit above overlapping neighbours (clustered bubbles
25143          // otherwise bury the one you are trying to read).
25144          function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
25145          function hl(lang){
25146            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))';raise(c);}else{c.style.opacity='0.1';c.style.filter='none';}}
25147            for(var t=0;t<labs.length;t++){var lx=labs[t];if(lx.getAttribute('data-lang')===lang){lx.style.opacity='1';lx.style.fontWeight='800';raise(lx);}else{lx.style.opacity='0.07';}}
25148            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
25149          function rst(){for(var i=0;i<circs.length;i++){circs[i].style.opacity='';circs[i].style.filter='';}for(var t=0;t<labs.length;t++){labs[t].style.opacity='';labs[t].style.fontWeight='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
25150          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]);}
25151        }
25152        function renderScatterInEl(el,hOvr){
25153          if(!el||!SCAT_D||!SCAT_D.length)return;
25154          var n=SCAT_D.length;
25155          var H=hOvr||300,PL=52,PB=36,PT=44;
25156          var W=Math.max(320,el.offsetWidth||480);
25157          var cH=H-PT-PB;
25158          // Legend: max 2 columns, fills vertical space. The compact card shows the
25159          // top languages by code lines plus a "+N more" row linking to Full View;
25160          // Full View (hOvr set) shows every language across up to 2 tall columns.
25161          var compact=!hOvr;
25162          var availH=Math.max(120,H-24);
25163          var rowsFit=Math.max(2,Math.floor(availH/18));
25164          var legTrunc=compact&&(n>2*rowsFit);
25165          var legShown=legTrunc?(2*rowsFit-1):n;
25166          var legTotal=legTrunc?(2*rowsFit):n;
25167          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
25168          var legPerCol=Math.ceil(legTotal/legCols);
25169          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
25170          var legColW=hOvr?144:130;
25171          var LG=26;
25172          var legW=legCols*legColW;
25173          var cW=W-PL-LG-legW;
25174          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
25175          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
25176          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
25177          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
25178          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
25179          var logMaxF=Math.log1p(maxF);
25180          var s='<svg class="scat-svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25181          // Smooth the legend-hover fade so bubbles + labels animate together.
25182          s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
25183          // Y grid lines (linear)
25184          [0,0.25,0.5,0.75,1].forEach(function(t){
25185            var y=PT+cH*(1-t);
25186            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25187            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>';
25188          });
25189          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
25190          [0,0.25,0.5,0.75,1].forEach(function(t){
25191            var x=PL+cW*t;
25192            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
25193            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25194            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>';
25195          });
25196          // Full View (hOvr set) has the vertical room to show the per-bubble value
25197          // line; the compact card shows only the language label to avoid the
25198          // overlapping-label clutter seen when bubbles cluster together.
25199          var showVal=!!hOvr;
25200          SCAT_D.forEach(function(d,i){
25201            // X uses log1p so outlier languages (many files) don't push others to the far left
25202            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
25203            var cy2=PT+cH-d.code/maxC*cH;
25204            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
25205            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"/>';
25206            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
25207            if(showVal){
25208              var ty2=Math.max(24,px(cy2)-px(r)-3);
25209              var ty1=Math.max(12,ty2-14);
25210              s+='<text data-lang="'+esc(d.lang)+'" 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>';
25211              s+='<text data-lang="'+esc(d.lang)+'" 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>';
25212            }else{
25213              var ly2=Math.max(12,px(cy2)-px(r)-3);
25214              s+='<text data-lang="'+esc(d.lang)+'" 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>';
25215            }
25216          });
25217          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>';
25218          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>';
25219          // Legend (right side — top languages, max 2 columns, fills height)
25220          var legX=PL+cW+LG;
25221          var legBlockH=legPerCol*legRowH;
25222          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
25223          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
25224          for(var lk=0;lk<legShown;lk++){
25225            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
25226            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
25227            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;">';
25228            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25229            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
25230            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>';
25231            s+='</g>';
25232          }
25233          if(legTrunc){
25234            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25235            s+='<g data-more="1" style="cursor:pointer;">';
25236            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25237            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25238            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>';
25239            s+='</g>';
25240          }
25241          s+='</svg>';
25242          el.innerHTML=s;
25243          wireScatterLegend(el);
25244          var moreEl=el.querySelector('g[data-more]');
25245          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25246        }
25247        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25248
25249        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25250        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25251        // the old vertical column layout on wide containers.
25252        function renderSemanticInEl(el,key,sh){
25253          if(!el||!SEM_D||!SEM_D.length)return;
25254          var n2=SEM_D.length||1;
25255          var LW=112,SH=sh||Math.max(180,n2*28+26);
25256          var svgW=Math.max(320,el.offsetWidth||480);
25257          var BW=Math.max(120,svgW-LW-80);
25258          var topPad=4,botPad=14;
25259          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25260          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25261          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25262          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">';
25263          SEM_D.forEach(function(d,i){
25264            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25265            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>';
25266            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"/>';
25267            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>';
25268          });
25269          s+='</svg>';
25270          el.innerHTML=s;
25271        }
25272        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25273        var semSel=document.getElementById('r-semantic-metric');
25274        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25275        var semExpand=document.getElementById('r-semantic-expand');
25276        if(semExpand){
25277          semExpand.addEventListener('click',function(){
25278            var key=semSel?semSel.value:'functions';
25279            var n=SEM_D.length||1;
25280            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25281            var modalH=Math.min(Math.max(360,n*38+60),maxH);
25282            var overlay=document.createElement('div');
25283            overlay.className='r-chart-modal-overlay';
25284            var optHtml=
25285              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25286              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25287              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25288              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25289              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25290            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>';
25291            document.body.appendChild(overlay);
25292            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25293            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25294            var modalEl=document.getElementById('r-sem-modal-chart');
25295            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25296            var modalSel=document.getElementById('r-sem-modal-metric');
25297            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25298          });
25299        }
25300
25301        // ── Expand buttons: re-render charts at large size inside modal ──────────
25302        (function(){
25303          function makeExpandModal(title,mH,subtitle,ctrlHtml){
25304            var overlay=document.createElement('div');
25305            overlay.className='r-chart-modal-overlay';
25306            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25307            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25308            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>';
25309            document.body.appendChild(overlay);
25310            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25311            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25312            return overlay.querySelector('.r-expand-modal-chart');
25313          }
25314          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25315          var compExpandBtn=document.getElementById('r-composition-expand');
25316          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25317            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25318            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25319            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25320              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25321            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25322            if(wrap){
25323              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25324              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25325                btn.addEventListener('click',function(){
25326                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25327                  btn.classList.add('active');
25328                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25329                });
25330              });
25331            }
25332          });}
25333          var scatExpandBtn=document.getElementById('r-scatter-expand');
25334          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25335            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25336            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25337          });}
25338          var densExpandBtn=document.getElementById('r-density-expand');
25339          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25340            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25341            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25342            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25343          });}
25344          var avgExpandBtn=document.getElementById('r-avglines-expand');
25345          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25346            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25347            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25348            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25349          });}
25350          var subExpandBtn=document.getElementById('r-submodule-expand');
25351          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25352            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25353            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25354            var metCtrl=
25355              '<select class="r-chart-select" id="r-sub-modal-metric">'
25356              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25357              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25358              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25359              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25360              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25361              +'</select>';
25362            var sortCtrl=
25363              '<select class="r-chart-select" id="r-sub-modal-sort">'
25364              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25365              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25366              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25367              +'</select>';
25368            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25369            if(wrap){
25370              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25371              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25372              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25373              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25374              if(mSub)mSub.addEventListener('change',reRenderSub);
25375              if(mSort)mSort.addEventListener('change',reRenderSub);
25376            }
25377          });}
25378        })();
25379
25380        // ── Comment Density: comments / (code + comments) per language ───────────
25381        function renderDensityInEl(el,shOvr){
25382          if(!el||!LANG_D||!LANG_D.length)return;
25383          var n=LANG_D.length||1;
25384          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25385          var svgW=Math.max(320,el.offsetWidth||480);
25386          var BW=Math.max(120,svgW-LW-80);
25387          var topPad=4,botPad=26;
25388          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25389          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25390          var densities=LANG_D.map(function(d){
25391            var sig=(d.code||0)+(d.comments||0);
25392            return sig>0?(d.comments||0)/sig:0;
25393          });
25394          var maxDen=Math.max.apply(null,densities)||1;
25395          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">';
25396          LANG_D.forEach(function(d,i){
25397            var den=densities[i],bw=den/maxDen*BW;
25398            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25399            var pct=Math.round(den*100);
25400            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>';
25401            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"/>';
25402            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25403            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>';
25404          });
25405          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>';
25406          s+='</svg>';
25407          el.innerHTML=s;
25408        }
25409        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25410        renderDensity();
25411
25412        // ── Avg Lines per File: code / files per language ─────────────────────
25413        function renderAvgLinesInEl(el,shOvr){
25414          if(!el||!LANG_D||!LANG_D.length)return;
25415          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25416          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25417          var n=data.length||1;
25418          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25419          var svgW=Math.max(320,el.offsetWidth||480);
25420          var BW=Math.max(120,svgW-LW-80);
25421          var topPad=4,botPad=26;
25422          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25423          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25424          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25425          var maxAvg=Math.max.apply(null,avgs)||1;
25426          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">';
25427          data.forEach(function(d,i){
25428            var avg=avgs[i],bw=avg/maxAvg*BW;
25429            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25430            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>';
25431            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"/>';
25432            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25433            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>';
25434          });
25435          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>';
25436          s+='</svg>';
25437          el.innerHTML=s;
25438        }
25439        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25440        renderAvgLines();
25441
25442        // ── Repository Overview: overall row + per-submodule rows ────────────
25443        function renderSubmoduleInEl(el,key,sort,shOvr){
25444          if(!el)return;
25445          var overall={
25446            name:'Overall',
25447            code:{{ code_lines }},
25448            comment:{{ comment_lines }},
25449            blank:{{ blank_lines }},
25450            physical:{{ physical_lines }},
25451            files:{{ files_analyzed }},
25452            isOverall:true
25453          };
25454          var subs=SUB_D.slice();
25455          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25456          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25457          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25458          var data=[overall].concat(subs);
25459          var sepH=subs.length>0?14:0;
25460          var naturalH=data.length*32+sepH+16;
25461          var SH=shOvr||Math.max(100,naturalH);
25462          var svgW=Math.max(320,el.offsetWidth||480);
25463          var LW=116,BW=Math.max(200,svgW-LW-54);
25464          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25465          var OVERALL_COL='#6b7280';
25466          var topPad=4,botPad=8;
25467          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25468          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25469          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">';
25470          var yOff=topPad;
25471          data.forEach(function(d,i){
25472            var v=d[key]||0,bw=v/maxV*BW;
25473            var y=yOff+Math.floor((rowSlot-bH)/2);
25474            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25475            var label=d.name||d.path||'?';
25476            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>';
25477            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25478            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25479            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>';
25480            yOff+=rowSlot;
25481            if(d.isOverall&&subs.length>0){
25482              yOff+=sepH;
25483            }
25484          });
25485          s+='</svg>';
25486          el.innerHTML=s;
25487        }
25488        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25489        var subSel=document.getElementById('r-sub-metric');
25490        var sortSel=document.getElementById('r-sub-sort');
25491        renderSubmodule('code','desc');
25492        if(subSel){
25493          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25494          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25495        }
25496
25497        // Equalise heights within each chart row: if one chart in a grid row is taller
25498        // than its neighbour, re-render the shorter one at the taller height so bars fill
25499        // the available vertical space instead of leaving a gap.
25500        function syncRowHeights(){
25501          var avgEl=document.getElementById('r-avglines-chart');
25502          var subEl=document.getElementById('r-submodule-chart');
25503          if(avgEl&&subEl){
25504            var avgSvg=avgEl.querySelector('svg');
25505            var subSvg=subEl.querySelector('svg');
25506            if(avgSvg&&subSvg){
25507              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25508              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25509              var key=subSel?subSel.value||'code':'code';
25510              var sort=sortSel?sortSel.value:'desc';
25511              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25512              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25513            }
25514          }
25515          var semEl=document.getElementById('r-semantic-chart');
25516          var denEl=document.getElementById('r-density-chart');
25517          if(semEl&&denEl){
25518            var semSvg=semEl.querySelector('svg');
25519            var denSvg=denEl.querySelector('svg');
25520            if(semSvg&&denSvg){
25521              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25522              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25523              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25524              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25525            }
25526          }
25527        }
25528        syncRowHeights();
25529
25530        // Re-render all SVG charts when the window is resized so bars fill the card.
25531        var _rResizeTimer;
25532        window.addEventListener('resize',function(){
25533          clearTimeout(_rResizeTimer);
25534          _rResizeTimer=setTimeout(function(){
25535            var rcompBtn=document.querySelector('[data-rcomp].active');
25536            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25537            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25538            if(semSel)renderSemantic(semSel.value||'functions');
25539            renderDensity();
25540            renderAvgLines();
25541            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25542            syncRowHeights();
25543          },120);
25544        });
25545      })();
25546
25547      (function randomizeWatermarks() {
25548        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25549        if (!wms.length) return;
25550        var placed = [];
25551        function tooClose(top, left) {
25552          for (var i = 0; i < placed.length; i++) {
25553            var dt = Math.abs(placed[i][0] - top);
25554            var dl = Math.abs(placed[i][1] - left);
25555            if (dt < 20 && dl < 18) return true;
25556          }
25557          return false;
25558        }
25559        function pick(leftBand) {
25560          for (var attempt = 0; attempt < 50; attempt++) {
25561            var top = Math.random() * 85 + 5;
25562            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25563            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25564          }
25565          var top = Math.random() * 85 + 5;
25566          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25567          placed.push([top, left]);
25568          return [top, left];
25569        }
25570        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25571        var half = Math.floor(wms.length / 2);
25572        wms.forEach(function (img, i) {
25573          var pos = pick(i < half);
25574          var size = Math.floor(Math.random() * 100 + 160);
25575          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25576          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25577          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;
25578        });
25579      })();
25580
25581      (function spawnCodeParticles() {
25582        var container = document.getElementById('code-particles');
25583        if (!container) return;
25584        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'];
25585        for (var i = 0; i < 38; i++) {
25586          (function(idx) {
25587            var el = document.createElement('span');
25588            el.className = 'code-particle';
25589            el.textContent = snippets[idx % snippets.length];
25590            var left = Math.random() * 94 + 2;
25591            var top = Math.random() * 88 + 6;
25592            var dur = (Math.random() * 10 + 9).toFixed(1);
25593            var delay = (Math.random() * 18).toFixed(1);
25594            var rot = (Math.random() * 26 - 13).toFixed(1);
25595            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25596            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';
25597            container.appendChild(el);
25598          })(i);
25599        }
25600      })();
25601
25602      {% if pdf_generating %}
25603      // Poll for PDF readiness and swap the disabled button to a live link once done.
25604      (function() {
25605        var openBtn = document.getElementById('pdf-open-btn');
25606        var dlBtn = document.getElementById('pdf-download-btn');
25607        function checkPdf() {
25608          fetch('/api/runs/{{ run_id }}/pdf-status')
25609            .then(function(r) { return r.json(); })
25610            .then(function(d) {
25611              if (d.ready) {
25612                if (openBtn) {
25613                  var a = document.createElement('a');
25614                  a.className = 'button';
25615                  a.id = 'pdf-open-btn';
25616                  a.href = '/runs/pdf/{{ run_id }}';
25617                  a.target = '_blank';
25618                  a.rel = 'noopener';
25619                  a.textContent = 'Open PDF';
25620                  openBtn.replaceWith(a);
25621                }
25622                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25623              } else {
25624                setTimeout(checkPdf, 3000);
25625              }
25626            })
25627            .catch(function() { setTimeout(checkPdf, 5000); });
25628        }
25629        setTimeout(checkPdf, 3000);
25630      })();
25631      {% endif %}
25632
25633    })();
25634  </script>
25635  <script nonce="{{ csp_nonce }}">
25636  (function(){
25637    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'}];
25638    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);});}
25639    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25640    function init(){
25641      var btn=document.getElementById('settings-btn');if(!btn)return;
25642      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25643      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>';
25644      document.body.appendChild(m);
25645      var g=document.getElementById('scheme-grid');
25646      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);});
25647      var cl=document.getElementById('settings-close');
25648      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
25649      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');});
25650      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25651      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25652    }
25653    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25654  }());
25655  </script>
25656  <footer class="site-footer">
25657    local code analysis - metrics, history and reports
25658    &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>
25659    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25660    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25661    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25662    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25663  </footer>
25664  {% if confluence_configured %}
25665  <script nonce="{{ csp_nonce }}">
25666  (function() {
25667    var postBtn = document.getElementById('postConfluenceBtn');
25668    var copyBtn = document.getElementById('copyWikiBtn');
25669    var modal   = document.getElementById('confluenceModal');
25670    if (!postBtn || !modal) return;
25671
25672    postBtn.addEventListener('click', function() {
25673      document.getElementById('confStatus').style.display = 'none';
25674      modal.style.display = 'flex';
25675    });
25676    document.getElementById('confCancelBtn').addEventListener('click', function() {
25677      modal.style.display = 'none';
25678    });
25679    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25680
25681    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25682      var btn = this;
25683      btn.disabled = true;
25684      var status = document.getElementById('confStatus');
25685      status.style.display = 'block';
25686      status.style.background = '#dbeafe';
25687      status.style.color = '#1e40af';
25688      status.textContent = 'Posting to Confluence\u2026';
25689      var resp = await fetch('/api/confluence/post', {
25690        method: 'POST',
25691        headers: { 'Content-Type': 'application/json' },
25692        body: JSON.stringify({
25693          run_id: '{{ run_id }}',
25694          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25695          report_url: document.getElementById('confReportUrl').value.trim() || null
25696        })
25697      });
25698      var data = await resp.json();
25699      if (data.ok) {
25700        status.style.background = '#dcfce7'; status.style.color = '#166534';
25701        status.textContent = 'Posted! Page ID: ' + data.page_id;
25702      } else {
25703        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25704        status.textContent = 'Error: ' + (data.error || 'Unknown error');
25705      }
25706      btn.disabled = false;
25707    });
25708
25709    if (copyBtn) {
25710      copyBtn.addEventListener('click', async function() {
25711        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25712        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25713        var text = await resp.text();
25714        try {
25715          await navigator.clipboard.writeText(text);
25716          var orig = copyBtn.textContent;
25717          copyBtn.textContent = 'Copied!';
25718          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25719        } catch(e) {
25720          alert('Clipboard write failed \u2014 check browser permissions.');
25721        }
25722      });
25723    }
25724  })();
25725  </script>
25726  {% endif %}
25727  <script nonce="{{ csp_nonce }}">
25728  (function() {
25729    var deleteBtn = document.getElementById('delete-run-btn');
25730    var modal     = document.getElementById('delete-run-modal');
25731    var cancelBtn = document.getElementById('delete-run-cancel');
25732    var confirmBtn= document.getElementById('delete-run-confirm');
25733    if (!deleteBtn || !modal) return;
25734    deleteBtn.addEventListener('click', function() {
25735      document.getElementById('delete-run-status').style.display = 'none';
25736      modal.style.display = 'flex';
25737    });
25738    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25739    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25740    confirmBtn.addEventListener('click', async function() {
25741      confirmBtn.disabled = true;
25742      cancelBtn.disabled = true;
25743      var status = document.getElementById('delete-run-status');
25744      status.style.display = 'block';
25745      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25746      status.textContent = 'Deleting\u2026';
25747      try {
25748        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25749        if (resp.status === 204 || resp.ok) {
25750          status.style.background = '#dcfce7'; status.style.color = '#166534';
25751          status.textContent = 'Deleted. Redirecting\u2026';
25752          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25753        } else {
25754          var d = await resp.json().catch(function(){return {};});
25755          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25756          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25757          confirmBtn.disabled = false;
25758          cancelBtn.disabled = false;
25759        }
25760      } catch (e) {
25761        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25762        status.textContent = 'Network error: ' + String(e);
25763        confirmBtn.disabled = false;
25764        cancelBtn.disabled = false;
25765      }
25766    });
25767  })();
25768  </script>
25769  <script nonce="{{ csp_nonce }}">(function(){
25770    var bundleBtn = document.getElementById('download-bundle-btn');
25771    if (bundleBtn) {
25772      bundleBtn.addEventListener('click', function() {
25773        bundleBtn.disabled = true;
25774        var orig = bundleBtn.textContent;
25775        bundleBtn.textContent = 'Preparing\u2026';
25776        fetch('/api/runs/{{ run_id }}/bundle')
25777          .then(function(r) {
25778            if (!r.ok) throw new Error('HTTP ' + r.status);
25779            return r.blob();
25780          })
25781          .then(function(blob) {
25782            var url = URL.createObjectURL(blob);
25783            var a = document.createElement('a');
25784            a.href = url;
25785            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25786            document.body.appendChild(a);
25787            a.click();
25788            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25789            bundleBtn.disabled = false;
25790            bundleBtn.textContent = orig;
25791          })
25792          .catch(function(e) {
25793            bundleBtn.disabled = false;
25794            bundleBtn.textContent = orig;
25795            alert('Bundle download failed: ' + String(e));
25796          });
25797      });
25798    }
25799  })();</script>
25800  <script nonce="{{ csp_nonce }}">(function(){
25801    var dot=document.getElementById('status-dot');
25802    var pingEl=document.getElementById('server-ping-ms');
25803    var tipEl=document.getElementById('server-tip-ping');
25804    var fm=document.getElementById('footer-mode');
25805    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)';}}
25806    function doPing(){
25807      var t0=performance.now();
25808      fetch('/healthz',{cache:'no-store'})
25809        .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);})
25810        .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)';}});
25811    }
25812    doPing();
25813    setInterval(doPing,5000);
25814    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');}
25815  })();</script>
25816  <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>
25817  {% if let Some(banner) = report_header_footer %}
25818  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25819  {% endif %}
25820</body>
25821</html>
25822"##,
25823    ext = "html"
25824)]
25825// Template structs need many bool fields to pass Askama rendering flags.
25826#[allow(clippy::struct_excessive_bools)]
25827struct ResultTemplate {
25828    version: &'static str,
25829    report_title: String,
25830    project_path: String,
25831    output_dir: String,
25832    run_id: String,
25833    files_analyzed: u64,
25834    files_skipped: u64,
25835    physical_lines: u64,
25836    code_lines: u64,
25837    comment_lines: u64,
25838    blank_lines: u64,
25839    mixed_lines: u64,
25840    functions: u64,
25841    classes: u64,
25842    variables: u64,
25843    imports: u64,
25844    html_url: Option<String>,
25845    pdf_url: Option<String>,
25846    json_url: Option<String>,
25847    html_download_url: Option<String>,
25848    pdf_download_url: Option<String>,
25849    json_download_url: Option<String>,
25850    html_path: Option<String>,
25851    json_path: Option<String>,
25852    prev_run_id: Option<String>,
25853    prev_run_timestamp: Option<String>,
25854    prev_run_code_lines: Option<u64>,
25855    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
25856    prev_fa_str: String,
25857    prev_fs_str: String,
25858    prev_pl_str: String,
25859    prev_cl_str: String,
25860    prev_cml_str: String,
25861    prev_bl_str: String,
25862    // Signed change column for main metrics
25863    delta_fa_str: String,
25864    delta_fa_class: String,
25865    delta_fs_str: String,
25866    delta_fs_class: String,
25867    delta_pl_str: String,
25868    delta_pl_class: String,
25869    delta_cl_str: String,
25870    delta_cl_class: String,
25871    delta_cml_str: String,
25872    delta_cml_class: String,
25873    delta_bl_str: String,
25874    delta_bl_class: String,
25875    // delta vs previous scan
25876    delta_lines_added: Option<i64>,
25877    delta_lines_removed: Option<i64>,
25878    delta_lines_net_str: String,
25879    delta_lines_net_class: String,
25880    delta_files_added: Option<usize>,
25881    delta_files_removed: Option<usize>,
25882    delta_files_modified: Option<usize>,
25883    delta_files_unchanged: Option<usize>,
25884    delta_files_total: Option<usize>,
25885    delta_unmodified_lines: Option<u64>,
25886    // git context
25887    git_branch: Option<String>,
25888    git_branch_url: Option<String>,
25889    git_commit: Option<String>,
25890    git_commit_long: Option<String>,
25891    git_author: Option<String>,
25892    git_commit_url: Option<String>,
25893    // scan metadata for hero section
25894    scan_performed_by: String,
25895    scan_time_display: String,
25896    scan_time_utc_ms: i64,
25897    os_display: String,
25898    test_count: u64,
25899    // reserve "pad" card, revealed by JS only when the visible card count is odd
25900    test_assertion_count: u64,
25901    // history
25902    prev_scan_count: usize,
25903    current_scan_number: usize,
25904    // submodule breakdown (empty when not requested)
25905    submodule_rows: Vec<SubmoduleRow>,
25906    scan_config_url: String,
25907    lang_chart_json: String,
25908    // Askama reads these via proc-macro expansion; clippy can't trace through it.
25909    #[allow(dead_code)]
25910    scatter_chart_json: String,
25911    #[allow(dead_code)]
25912    semantic_chart_json: String,
25913    #[allow(dead_code)]
25914    submodule_chart_json: String,
25915    #[allow(dead_code)]
25916    has_submodule_data: bool,
25917    #[allow(dead_code)]
25918    has_semantic_data: bool,
25919    pdf_generating: bool,
25920    csp_nonce: String,
25921    /// Whether Confluence integration is configured — shows Post button when true.
25922    confluence_configured: bool,
25923    server_mode: bool,
25924    /// Header/footer identification banner, mirrored from the HTML/PDF report.
25925    report_header_footer: Option<String>,
25926    run_id_short: String,
25927    /// True when rendering a static offline file (index.html); hides server-only actions.
25928    #[allow(dead_code)]
25929    is_offline: bool,
25930    /// Total cyclomatic complexity score across all analyzed files.
25931    cyclomatic_complexity: u64,
25932    /// Logical SLOC (statement count) when available; None for unsupported languages.
25933    lsloc: Option<u64>,
25934    /// Unique Lines of Code across all analyzed files.
25935    uloc: u64,
25936    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
25937    dryness_pct_str: String,
25938    /// Number of duplicate file groups detected.
25939    duplicate_group_count: usize,
25940    /// Whether a COCOMO estimate is available to display.
25941    has_cocomo: bool,
25942    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
25943    cocomo_effort_str: String,
25944    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
25945    cocomo_duration_str: String,
25946    /// Pre-formatted average team size, e.g. "2.32".
25947    cocomo_staff_str: String,
25948    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
25949    cocomo_ksloc_str: String,
25950    /// COCOMO mode label shown in the card (e.g. "Organic").
25951    cocomo_mode_label: String,
25952    /// Tooltip text explaining the selected COCOMO mode.
25953    cocomo_mode_tooltip: String,
25954    /// Per-file complexity alert threshold. 0 = off (no highlighting).
25955    complexity_alert: u32,
25956    /// Whether any file has coverage data attached.
25957    has_coverage_data: bool,
25958    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
25959    cov_line_pct: String,
25960    /// Overall function coverage percentage string — empty if no data.
25961    cov_fn_pct: String,
25962    /// Overall branch coverage percentage string — empty if no branch data.
25963    cov_branch_pct: String,
25964    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
25965    cov_lines_summary: String,
25966}
25967
25968#[derive(Template)]
25969#[template(
25970    source = r##"
25971<!doctype html>
25972<html lang="en">
25973<head>
25974  <meta charset="utf-8">
25975  <meta name="viewport" content="width=device-width, initial-scale=1">
25976  <title>OxideSLOC | Analyzing…</title>
25977  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25978  <style nonce="{{ csp_nonce }}">
25979    :root {
25980      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25981      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25982      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25983      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25984    }
25985    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25986    *{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;}
25987    .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);}
25988    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25989    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25990    .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));}
25991    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25992    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25993    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25994    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25995    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25996    @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; } }
25997    .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;}
25998    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25999    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26000    .page-body{padding:32px 24px 36px;}
26001    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
26002    .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;}
26003    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
26004    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
26005    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
26006    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
26007    .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;}
26008    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
26009    .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;}
26010    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
26011    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
26012    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
26013    .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;}
26014    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
26015    .hidden{display:none!important;}
26016    .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;}
26017    .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;}
26018    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
26019    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
26020    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
26021    .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);}
26022    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
26023    .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;}
26024    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
26025    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26026    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26027    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
26028    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26029    .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;}
26030    @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));}}
26031    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26032    .site-footer a{color:var(--muted);}
26033    .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;}
26034    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
26035    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
26036    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
26037  </style>
26038</head>
26039<body>
26040  <div class="background-watermarks" aria-hidden="true">
26041    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26042    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26043    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26044    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26045    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26046    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26047  </div>
26048  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26049  <nav class="top-nav">
26050    <div class="top-nav-inner">
26051      <a href="/" class="brand">
26052        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
26053        <div class="brand-copy">
26054          <h1 class="brand-title">OxideSLOC</h1>
26055          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26056        </div>
26057      </a>
26058      <div class="nav-right">
26059        <a class="nav-pill" href="/">Home</a>
26060        <div class="nav-dropdown">
26061          <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>
26062          <div class="nav-dropdown-menu">
26063            <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>
26064          </div>
26065        </div>
26066        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26067        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26068        <div class="nav-dropdown">
26069          <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>
26070          <div class="nav-dropdown-menu">
26071            <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>
26072          </div>
26073        </div>
26074        <div class="server-status-wrap" id="server-status-wrap">
26075          <div class="nav-pill server-online-pill" id="server-status-pill">
26076            <span class="status-dot" id="status-dot"></span>
26077            <span id="server-status-label">Server</span>
26078            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26079          </div>
26080          <div class="server-status-tip">
26081            OxideSLOC is running — accessible on your network.
26082            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26083          </div>
26084        </div>
26085        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26086          <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>
26087        </button>
26088        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26089          <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>
26090          <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>
26091        </button>
26092      </div>
26093    </div>
26094  </nav>
26095  <div class="page-body">
26096    <div class="wait-panel">
26097      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
26098      <h2 class="wait-title">Analyzing your project…</h2>
26099      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
26100      <div class="path-block">{{ project_path }}</div>
26101      <div class="metrics-row">
26102        <div class="metric-card">
26103          <div class="metric-label">Elapsed</div>
26104          <div class="metric-value" id="elapsed">0s</div>
26105        </div>
26106        <div class="metric-card">
26107          <div class="metric-label">Phase</div>
26108          <div class="metric-value" id="phase">Starting</div>
26109        </div>
26110        <div class="metric-card hidden" id="files-card">
26111          <div class="metric-label">Files</div>
26112          <div class="metric-value" id="files-progress">0</div>
26113        </div>
26114      </div>
26115      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
26116      <div class="warn-slow hidden" id="warn-slow">
26117        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.
26118      </div>
26119      <div class="err-panel hidden" id="err-panel">
26120        <strong>Analysis failed</strong>
26121        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
26122      </div>
26123      <div class="actions hidden" id="actions">
26124        <a href="/scan" class="btn-primary">Try Again</a>
26125        <a href="/view-reports" class="btn-outline">View Reports</a>
26126      </div>
26127    </div>
26128  </div>
26129  <script nonce="{{ csp_nonce }}">
26130    (function() {
26131      var WAIT_ID = {{ wait_id_json|safe }};
26132      var startTime = Date.now();
26133      var pollInterval = 1500;
26134      var retries = 0;
26135      var maxRetries = 5;
26136      var warnShown = false;
26137
26138      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();}
26139
26140      function elapsed() {
26141        return Math.floor((Date.now() - startTime) / 1000);
26142      }
26143
26144      function updateElapsed() {
26145        var s = elapsed();
26146        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
26147      }
26148
26149      function setPhase(txt) {
26150        document.getElementById('phase').textContent = txt;
26151      }
26152
26153      var elapsedTimer = setInterval(updateElapsed, 1000);
26154
26155      function poll() {
26156        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
26157          .then(function(r) {
26158            if (!r.ok) throw new Error('HTTP ' + r.status);
26159            return r.json();
26160          })
26161          .then(function(data) {
26162            retries = 0;
26163            if (data.state === 'complete') {
26164              clearInterval(elapsedTimer);
26165              setPhase('Done');
26166              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
26167            } else if (data.state === 'failed') {
26168              clearInterval(elapsedTimer);
26169              setPhase('Failed');
26170              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
26171              document.getElementById('err-panel').classList.remove('hidden');
26172              document.getElementById('actions').classList.remove('hidden');
26173            } else {
26174              // still running
26175              var s = elapsed();
26176              if (s > 90 && !warnShown) {
26177                warnShown = true;
26178                document.getElementById('warn-slow').classList.remove('hidden');
26179              }
26180              setPhase(data.phase || 'Running');
26181              var fd = data.files_done || 0, ft = data.files_total || 0;
26182              if (ft > 0) {
26183                var card = document.getElementById('files-card');
26184                if (card) card.classList.remove('hidden');
26185                var fp = document.getElementById('files-progress');
26186                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
26187              }
26188              setTimeout(poll, pollInterval);
26189            }
26190          })
26191          .catch(function(err) {
26192            retries++;
26193            if (retries >= maxRetries) {
26194              clearInterval(elapsedTimer);
26195              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
26196              document.getElementById('err-panel').classList.remove('hidden');
26197              document.getElementById('actions').classList.remove('hidden');
26198            } else {
26199              // exponential back-off capped at 8s
26200              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
26201            }
26202          });
26203      }
26204
26205      setTimeout(poll, pollInterval);
26206
26207      // If the browser restores this page from bfcache (Back after viewing results),
26208      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
26209      window.addEventListener("pageshow", function(e) {
26210        if (e.persisted) { setTimeout(poll, 200); }
26211      });
26212    })();
26213  </script>
26214  <footer class="site-footer">
26215    local code analysis - metrics, history and reports
26216    &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>
26217    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26218    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26219    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26220    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26221  </footer>
26222  <script nonce="{{ csp_nonce }}">
26223    (function(){
26224      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26225      if(s==="dark")b.classList.add("dark-theme");
26226      var tt=document.getElementById("theme-toggle");
26227      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
26228    })();
26229    (function spawnCodeParticles(){
26230      var c=document.getElementById('code-particles');if(!c)return;
26231      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'];
26232      for(var i=0;i<32;i++){(function(idx){
26233        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26234        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26235        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26236        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26237        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26238        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26239        c.appendChild(el);
26240      })(i);}
26241    })();
26242    (function randomizeWatermarks(){
26243      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26244      var placed=[];
26245      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;}
26246      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];}
26247      var half=Math.floor(wms.length/2);
26248      wms.forEach(function(img,i){
26249        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26250        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26251        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26252        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26253        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26254        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26255      });
26256    })();
26257  </script>
26258  <script nonce="{{ csp_nonce }}">
26259  (function(){
26260    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'}];
26261    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);});}
26262    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26263    function init(){
26264      var btn=document.getElementById('settings-btn');if(!btn)return;
26265      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26266      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>';
26267      document.body.appendChild(m);
26268      var g=document.getElementById('scheme-grid');
26269      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);});
26270      var cl=document.getElementById('settings-close');
26271      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
26272      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');});
26273      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26274      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26275    }
26276    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26277  }());
26278  </script>
26279  <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]';
26280  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;}
26281  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>
26282</body>
26283</html>
26284"##,
26285    ext = "html"
26286)]
26287struct ScanWaitTemplate {
26288    version: &'static str,
26289    wait_id_json: String,
26290    project_path: String,
26291    csp_nonce: String,
26292}
26293
26294#[derive(Template)]
26295#[template(
26296    source = r##"
26297<!doctype html>
26298<html lang="en">
26299<head>
26300  <meta charset="utf-8">
26301  <meta name="viewport" content="width=device-width, initial-scale=1">
26302  <title>OxideSLOC | Error</title>
26303  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26304  <style nonce="{{ csp_nonce }}">
26305    :root {
26306      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26307      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26308      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26309      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26310    }
26311    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26312    *{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;}
26313    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26314    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26315    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26316    .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);}
26317    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26318    .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));}
26319    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26320    .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;}
26321    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26322    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26323    @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; } }
26324    .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;}
26325    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26326    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26327    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26328    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26329    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26330    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
26331    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26332    .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);}
26333    .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;}
26334    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26335    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26336    .settings-modal-body{padding:14px 16px 16px;}
26337    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26338    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26339    .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;}
26340    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26341    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26342    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26343    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26344    .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;}
26345    .tz-select:focus{border-color:var(--oxide);}
26346    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26347    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26348    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26349    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26350    .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;}
26351    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26352    .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);}
26353    .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;}
26354    .btn-secondary:hover{background:var(--line);}
26355    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26356    .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;}
26357    .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;}
26358    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26359    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26360    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26361    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26362    .bug-report-panel.open{display:flex;}
26363    .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;}
26364    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26365    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26366    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26367    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26368    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26369    .br-network-badge.online .br-net-dot{background:#2a6846;}
26370    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26371    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26372    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26373    .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;}
26374    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26375    .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;}
26376    .btn-sm:hover{background:var(--line);}
26377    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26378    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26379    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26380    .bug-report-hint a:hover{text-decoration:underline;}
26381    .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;}
26382    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26383    .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;}
26384    .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;}
26385    .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;}
26386    @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));}}
26387    .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;}
26388  </style>
26389</head>
26390<body>
26391  <div class="background-watermarks" aria-hidden="true">
26392    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26393    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26394    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26395    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26396    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26397    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26398  </div>
26399  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26400  <div class="top-nav">
26401    <div class="top-nav-inner">
26402      <a class="brand" href="/">
26403        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26404        <div class="brand-copy">
26405          <div class="brand-title">OxideSLOC</div>
26406          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26407        </div>
26408      </a>
26409      <div class="nav-right">
26410        <a class="nav-pill" href="/">Home</a>
26411        <div class="nav-dropdown">
26412          <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>
26413          <div class="nav-dropdown-menu">
26414            <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>
26415          </div>
26416        </div>
26417        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26418        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26419        <div class="nav-dropdown">
26420          <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>
26421          <div class="nav-dropdown-menu">
26422            <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>
26423          </div>
26424        </div>
26425        <div class="server-status-wrap" id="server-status-wrap">
26426          <div class="nav-pill server-online-pill" id="server-status-pill">
26427            <span class="status-dot" id="status-dot"></span>
26428            <span id="server-status-label">Server</span>
26429            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26430          </div>
26431          <div class="server-status-tip">
26432            OxideSLOC is running — accessible on your network.
26433            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26434          </div>
26435        </div>
26436        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26437          <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>
26438        </button>
26439        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26440          <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>
26441          <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>
26442        </button>
26443      </div>
26444    </div>
26445  </div>
26446
26447  <div class="page">
26448    <div class="panel">
26449      <h1>Error</h1>
26450      <div class="error-box" id="error-msg-text">{{ message }}</div>
26451      <div id="br-meta" hidden
26452        data-version="{{ version }}"
26453        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26454        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26455      <div class="actions">
26456        <a class="btn-primary" href="/scan">Back to setup</a>
26457        {% if let Some(report_url) = last_report_url %}
26458        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26459        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26460        {% else %}
26461        <a class="btn-secondary" href="/view-reports">View Reports</a>
26462        {% endif %}
26463      </div>
26464      <div class="bug-report-section" id="bug-report-section">
26465        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26466          <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>
26467          Generate Bug Report
26468          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26469        </button>
26470        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26471          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
26472          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
26473          <div class="bug-report-btns">
26474            <button type="button" class="btn-sm" id="bug-report-copy">
26475              <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>
26476              Copy to clipboard
26477            </button>
26478            <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;">
26479              <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>
26480              Open GitHub Issue
26481            </a>
26482            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26483              <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>
26484              Save as file
26485            </button>
26486          </div>
26487          <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>
26488          <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>
26489        </div>
26490      </div>
26491    </div>
26492  </div>
26493  <footer class="site-footer">
26494    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26495    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26496    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26497    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26498    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26499  </footer>
26500  <script nonce="{{ csp_nonce }}">(function(){
26501    var meta=document.getElementById('br-meta');
26502    var pre=document.getElementById('bug-report-pre');
26503    var copyBtn=document.getElementById('bug-report-copy');
26504    var trigger=document.getElementById('bug-report-trigger');
26505    var panel=document.getElementById('bug-report-panel');
26506    var networkBadge=document.getElementById('br-network-badge');
26507    var networkLabel=document.getElementById('br-network-label');
26508    var ghLink=document.getElementById('bug-report-github-link');
26509    var saveBtn=document.getElementById('bug-report-save');
26510    var hintOnline=document.getElementById('br-hint-online');
26511    var hintOffline=document.getElementById('br-hint-offline');
26512    if(!meta||!pre)return;
26513    var ver=meta.getAttribute('data-version')||'';
26514    var runId=meta.getAttribute('data-run-id')||'';
26515    var code=meta.getAttribute('data-error-code')||'';
26516    var msgEl=document.getElementById('error-msg-text');
26517    var msg=msgEl?msgEl.textContent.trim():'';
26518    function getBrowser(){
26519      var ua=navigator.userAgent;
26520      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26521      if(!m)return 'Unknown browser';
26522      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26523      return n+' '+m[2];
26524    }
26525    var lines=['oxide-sloc Bug Report','==============================',''];
26526    lines.push('App version:  v'+ver);
26527    if(code)lines.push('HTTP status:  '+code);
26528    if(runId)lines.push('Run ID:       '+runId);
26529    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
26530    lines.push('Timestamp:    '+new Date().toISOString());
26531    lines.push('Browser:      '+getBrowser());
26532    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
26533    lines.push('');
26534    lines.push('Error message:');
26535    lines.push(msg);
26536    lines.push('');
26537    lines.push('Steps to reproduce:');
26538    lines.push('  1. ');
26539    lines.push('');
26540    lines.push('Expected behavior:');
26541    lines.push('  ');
26542    pre.textContent=lines.join('\n');
26543    function applyNetwork(online){
26544      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26545      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26546      if(ghLink){
26547        if(online){
26548          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26549          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26550        }
26551        ghLink.style.display=online?'inline-flex':'none';
26552      }
26553      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26554      if(hintOnline)hintOnline.style.display=online?'block':'none';
26555      if(hintOffline)hintOffline.style.display=online?'none':'block';
26556    }
26557    applyNetwork(navigator.onLine);
26558    var probed=false;
26559    function probeNetwork(){
26560      if(probed)return;probed=true;
26561      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26562      var probeIdx=0;
26563      function tryNext(){
26564        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26565        var u=probeUrls[probeIdx++];
26566        var c2=new AbortController();
26567        var t2=setTimeout(function(){c2.abort();},4000);
26568        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26569          .then(function(){clearTimeout(t2);applyNetwork(true);})
26570          .catch(function(){clearTimeout(t2);tryNext();});
26571      }
26572      tryNext();
26573    }
26574    if(trigger&&panel){
26575      trigger.addEventListener('click',function(){
26576        var open=panel.classList.toggle('open');
26577        trigger.classList.toggle('open',open);
26578        trigger.setAttribute('aria-expanded',open?'true':'false');
26579        if(open)probeNetwork();
26580      });
26581    }
26582    if(copyBtn){
26583      copyBtn.addEventListener('click',function(){
26584        var txt=pre.textContent;
26585        if(navigator.clipboard&&navigator.clipboard.writeText){
26586          navigator.clipboard.writeText(txt).then(function(){
26587            copyBtn.textContent='\u2713 Copied!';
26588            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);
26589          });
26590        }else{
26591          var ta=document.createElement('textarea');
26592          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26593          document.body.appendChild(ta);ta.select();
26594          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26595          document.body.removeChild(ta);
26596        }
26597      });
26598    }
26599    if(saveBtn){
26600      saveBtn.addEventListener('click',function(){
26601        var txt=pre.textContent;
26602        var blob=new Blob([txt],{type:'text/plain'});
26603        var url=URL.createObjectURL(blob);
26604        var a=document.createElement('a');
26605        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26606        document.body.appendChild(a);a.click();
26607        document.body.removeChild(a);URL.revokeObjectURL(url);
26608      });
26609    }
26610  })();</script>
26611  <script nonce="{{ csp_nonce }}">
26612    (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");});})();
26613    (function spawnCodeParticles() {
26614      var container = document.getElementById('code-particles');
26615      if (!container) return;
26616      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'];
26617      for (var i = 0; i < 38; i++) {
26618        (function(idx) {
26619          var el = document.createElement('span');
26620          el.className = 'code-particle';
26621          el.textContent = snippets[idx % snippets.length];
26622          var left = Math.random() * 94 + 2;
26623          var top = Math.random() * 88 + 6;
26624          var dur = (Math.random() * 10 + 9).toFixed(1);
26625          var delay = (Math.random() * 18).toFixed(1);
26626          var rot = (Math.random() * 26 - 13).toFixed(1);
26627          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26628          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';
26629          container.appendChild(el);
26630        })(i);
26631      }
26632    })();
26633    (function randomizeWatermarks() {
26634      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26635      var placed = [];
26636      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; }
26637      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]; }
26638      var half = Math.floor(wms.length/2);
26639      wms.forEach(function(img, i) {
26640        var pos = pick(i < half);
26641        var w = Math.floor(Math.random()*60+80);
26642        var rot = (Math.random()*40-20).toFixed(1);
26643        var op = (Math.random()*0.08+0.05).toFixed(2);
26644        var animDur = (Math.random()*6+5).toFixed(1);
26645        var animDelay = (Math.random()*10).toFixed(1);
26646        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';
26647      });
26648    })();
26649  </script>
26650  <script nonce="{{ csp_nonce }}">
26651  (function(){
26652    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'}];
26653    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);});}
26654    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26655    function init(){
26656      var btn=document.getElementById('settings-btn');if(!btn)return;
26657      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26658      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>';
26659      document.body.appendChild(m);
26660      var g=document.getElementById('scheme-grid');
26661      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);});
26662      var cl=document.getElementById('settings-close');
26663      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
26664      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');});
26665      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26666      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26667    }
26668    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26669  }());
26670  </script>
26671  <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]';
26672  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;}
26673  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>
26674</body>
26675</html>
26676"##,
26677    ext = "html"
26678)]
26679struct ErrorTemplate {
26680    message: String,
26681    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
26682    last_report_url: Option<String>,
26683    /// Label for the secondary action button; defaults to "View last report" when None.
26684    last_report_label: Option<String>,
26685    /// Run ID to surface in the bug report; `None` when not applicable.
26686    run_id: Option<String>,
26687    /// HTTP status code to surface in the bug report; `None` when unknown.
26688    error_code: Option<u16>,
26689    csp_nonce: String,
26690    version: &'static str,
26691}
26692
26693// ── LocateFileTemplate ────────────────────────────────────────────────────────
26694
26695#[derive(Template)]
26696#[template(
26697    source = r##"
26698<!doctype html>
26699<html lang="en">
26700<head>
26701  <meta charset="utf-8">
26702  <meta name="viewport" content="width=device-width, initial-scale=1">
26703  <title>OxideSLOC | Locate Report</title>
26704  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26705  <style nonce="{{ csp_nonce }}">
26706    :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);}
26707    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26708    *{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;}
26709    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26710    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26711    .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);}
26712    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26713    .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));}
26714    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26715    .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;}
26716    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26717    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26718    @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;}}
26719    .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;}
26720    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26721    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26722    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26723    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26724    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26725    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
26726    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26727    .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);}
26728    .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;}
26729    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26730    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26731    .settings-modal-body{padding:14px 16px 16px;}
26732    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26733    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26734    .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;}
26735    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26736    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26737    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26738    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26739    .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;}
26740    .tz-select:focus{border-color:var(--oxide);}
26741    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26742    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26743    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26744    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26745    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26746    .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;}
26747    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26748    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26749    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26750    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26751    .locate-row{display:flex;gap:8px;align-items:stretch;}
26752    .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;}
26753    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26754    body.dark-theme .locate-input{background:var(--surface-2);}
26755    .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;}
26756    .warning-banner.show{display:flex;}
26757    .warning-banner svg{flex:0 0 auto;}
26758    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26759    .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;}
26760    .error-inline.show{display:flex;}
26761    .error-inline svg{flex:0 0 auto;margin-top:2px;}
26762    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26763    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26764    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26765    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26766    .err-kv-p{margin:0 0 4px;}
26767    .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;}
26768    .success-inline.show{display:flex;}
26769    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26770    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26771    .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;}
26772    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26773    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26774    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26775    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26776    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26777    .fh-row:last-child{border-bottom:none;}
26778    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26779    .fh-dir{font-weight:800;color:var(--text);}
26780    .fh-hl{color:var(--oxide);font-weight:700;}
26781    .fh-muted{color:var(--muted);}
26782    .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;}
26783    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26784    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26785    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26786    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26787    .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;}
26788    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26789    .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;}
26790    .btn-secondary:hover{background:var(--line);}
26791    .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;}
26792    .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;}
26793    .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;}
26794    @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));}}
26795    .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;}
26796    .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;}
26797    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26798  </style>
26799</head>
26800<body>
26801  <div class="background-watermarks" aria-hidden="true">
26802    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26803    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26804    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26805    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26806    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26807    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26808  </div>
26809  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26810  <div class="top-nav">
26811    <div class="top-nav-inner">
26812      <a class="brand" href="/">
26813        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26814        <div class="brand-copy">
26815          <div class="brand-title">OxideSLOC</div>
26816          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26817        </div>
26818      </a>
26819      <div class="nav-right">
26820        <a class="nav-pill" href="/">Home</a>
26821        <div class="nav-dropdown">
26822          <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>
26823          <div class="nav-dropdown-menu">
26824            <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>
26825          </div>
26826        </div>
26827        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26828        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26829        <div class="nav-dropdown">
26830          <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>
26831          <div class="nav-dropdown-menu">
26832            <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>
26833          </div>
26834        </div>
26835        <div class="server-status-wrap" id="server-status-wrap">
26836          <div class="nav-pill server-online-pill" id="server-status-pill">
26837            <span class="status-dot" id="status-dot"></span>
26838            <span id="server-status-label">Server</span>
26839            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26840          </div>
26841          <div class="server-status-tip">
26842            OxideSLOC is running &mdash; accessible on your network.
26843            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26844          </div>
26845        </div>
26846        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26847          <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>
26848        </button>
26849        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26850          <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>
26851          <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>
26852        </button>
26853      </div>
26854    </div>
26855  </div>
26856
26857  <div class="page">
26858    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26859    <div class="panel">
26860      <h1>Report File Not Found</h1>
26861      <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>
26862      <div class="field-label">Missing file</div>
26863      <div class="filename-chip">
26864        <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>
26865        {{ expected_filename }}
26866      </div>
26867      <div class="locate-section">
26868        <h2>Locate Scan Output Folder</h2>
26869        <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>
26870        <p>OxideSLOC will find the correct files inside automatically.</p>
26871        <div class="locate-row">
26872          <input type="text" id="locate-file-input"
26873                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26874                 class="locate-input" autocomplete="off" spellcheck="false">
26875          {% if !server_mode %}
26876          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
26877          {% endif %}
26878        </div>
26879        <div class="warning-banner" id="filename-warning">
26880          <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>
26881          <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>
26882        </div>
26883        <div class="error-inline" id="locate-error">
26884          <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>
26885          <span id="locate-error-text"></span>
26886        </div>
26887        <div class="success-inline" id="locate-success">
26888          <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>
26889          <span>Scan restored &mdash; loading report&hellip;</span>
26890        </div>
26891        <div class="btn-row">
26892          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26893          <a class="btn-secondary" href="/view-reports">View Reports</a>
26894        </div>
26895        <div class="folder-hint-shell">
26896          <div class="folder-hint-hdr">
26897            <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>
26898            Expected Folder Structure &mdash; Select the Top-Level Folder
26899          </div>
26900          <div class="folder-hint-body">
26901            <div class="fh-row">
26902              <span class="fh-tog">&#9658;</span>
26903              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
26904              <span class="fh-badge">&larr; select this</span>
26905            </div>
26906            <div class="fh-row fh-i1">
26907              <span class="fh-tog">&#9658;</span>
26908              <span class="fh-dir">html/</span>
26909            </div>
26910            <div class="fh-row fh-i2">
26911              <span class="fh-bul">&#8226;</span>
26912              <span class="fh-hl">{{ expected_filename }}</span>
26913            </div>
26914            <div class="fh-row fh-i1">
26915              <span class="fh-tog">&#9658;</span>
26916              <span class="fh-dir">json/</span>
26917            </div>
26918            <div class="fh-row fh-i2">
26919              <span class="fh-bul">&#8226;</span>
26920              <span class="fh-muted">result_*.json</span>
26921            </div>
26922            <div class="fh-row fh-i1">
26923              <span class="fh-tog">&#9658;</span>
26924              <span class="fh-dir">pdf/</span>
26925            </div>
26926            <div class="fh-row fh-i2">
26927              <span class="fh-bul">&#8226;</span>
26928              <span class="fh-muted">report_*.pdf</span>
26929            </div>
26930            <div class="fh-row fh-i1">
26931              <span class="fh-tog">&#9658;</span>
26932              <span class="fh-dir">excel/</span>
26933            </div>
26934            <div class="fh-row fh-i2">
26935              <span class="fh-bul">&#8226;</span>
26936              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
26937            </div>
26938          </div>
26939        </div>
26940      </div>
26941    </div>
26942  </div>
26943  <footer class="site-footer">
26944    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26945    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26946    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26947    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26948    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26949  </footer>
26950  <script nonce="{{ csp_nonce }}">(function(){
26951    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26952    if(s==="dark")b.classList.add("dark-theme");
26953    document.getElementById("theme-toggle").addEventListener("click",function(){
26954      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26955    });
26956  })();</script>
26957  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26958    var c=document.getElementById('code-particles');if(!c)return;
26959    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'];
26960    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);}
26961  })();
26962  (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>
26963  <script nonce="{{ csp_nonce }}">(function(){
26964    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'}];
26965    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);});}
26966    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26967    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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();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');});}
26968    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26969  }());</script>
26970  <script nonce="{{ csp_nonce }}">(function(){
26971    var meta=document.getElementById('locate-meta');
26972    var inp=document.getElementById('locate-file-input');
26973    var browseBtn=document.getElementById('browse-locate-btn');
26974    var submitBtn=document.getElementById('locate-submit-btn');
26975    var warning=document.getElementById('filename-warning');
26976    var errBox=document.getElementById('locate-error');
26977    var errText=document.getElementById('locate-error-text');
26978    var okBox=document.getElementById('locate-success');
26979    var expected=meta?meta.getAttribute('data-expected'):'';
26980    var runId=meta?meta.getAttribute('data-run-id'):'';
26981    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26982    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26983    function showErr(msg){
26984      if(errText){
26985        errText.innerHTML='';
26986        var lines=msg.split('\n');
26987        var hasPairs=lines.some(function(l){return / : /.test(l);});
26988        if(!hasPairs){errText.textContent=msg;}
26989        else{
26990          var frag=document.createDocumentFragment();var tbl=null;
26991          lines.forEach(function(line){
26992            var m=line.match(/^(.*?) : (.*)$/);
26993            if(m){
26994              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26995              var tr=document.createElement('tr');
26996              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26997              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26998              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26999            } else {
27000              tbl=null;
27001              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
27002            }
27003          });
27004          errText.appendChild(frag);
27005        }
27006      }
27007      if(errBox)errBox.classList.add('show');
27008      if(okBox)okBox.classList.remove('show');
27009    }
27010    function clearErr(){
27011      if(errBox)errBox.classList.remove('show');
27012      if(okBox)okBox.classList.remove('show');
27013    }
27014    function validate(){
27015      var val=inp?inp.value.trim():'';
27016      clearErr();
27017      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
27018      if(submitBtn)submitBtn.disabled=false;
27019      if(warning){
27020        var name=basename(val);
27021        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
27022        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
27023        else warning.classList.remove('show');
27024      }
27025    }
27026    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
27027    if(browseBtn){
27028      browseBtn.addEventListener('click',function(){
27029        browseBtn.disabled=true;browseBtn.textContent='...';
27030        fetch('/pick-directory')
27031          .then(function(r){return r.ok?r.json():{cancelled:true};})
27032          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
27033          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27034      });
27035    }
27036    if(submitBtn){
27037      submitBtn.addEventListener('click',function(){
27038        var folder=inp?inp.value.trim():'';
27039        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
27040        clearErr();
27041        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
27042        var body=new URLSearchParams();
27043        body.set('file_path',folder);
27044        body.set('redirect_url',redirectUrl);
27045        body.set('expected_run_id',runId);
27046        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27047          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
27048          .then(function(d){
27049            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27050            if(d&&d.ok){
27051              if(okBox)okBox.classList.add('show');
27052              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
27053            } else {
27054              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
27055            }
27056          })
27057          .catch(function(e){
27058            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27059            showErr('Network error: '+String(e));
27060          });
27061      });
27062    }
27063  })();</script>
27064  <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>
27065</body>
27066</html>
27067"##,
27068    ext = "html"
27069)]
27070struct LocateFileTemplate {
27071    run_id: String,
27072    artifact_type: String,
27073    expected_filename: String,
27074    server_mode: bool,
27075    csp_nonce: String,
27076    version: &'static str,
27077}
27078
27079// ── RelocateScanTemplate ──────────────────────────────────────────────────────
27080
27081#[derive(Template)]
27082#[template(
27083    source = r##"
27084<!doctype html>
27085<html lang="en">
27086<head>
27087  <meta charset="utf-8">
27088  <meta name="viewport" content="width=device-width, initial-scale=1">
27089  <title>OxideSLOC | Locate Scan Files</title>
27090  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27091  <style nonce="{{ csp_nonce }}">
27092    :root {
27093      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27094      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27095      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
27096      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27097    }
27098    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27099    *{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;}
27100    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27101    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27102    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
27103    .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);}
27104    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27105    .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));}
27106    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27107    .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;}
27108    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27109    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
27110    @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;}}
27111    .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;}
27112    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27113    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27114    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27115    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27116    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27117    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
27118    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27119    .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);}
27120    .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;}
27121    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27122    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27123    .settings-modal-body{padding:14px 16px 16px;}
27124    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27125    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27126    .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;}
27127    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27128    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27129    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27130    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27131    .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;}
27132    .tz-select:focus{border-color:var(--oxide);}
27133    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
27134    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
27135    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
27136    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
27137    .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;}
27138    .error-box.hidden{display:none;}
27139    .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;}
27140    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
27141    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
27142    .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;}
27143    .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;}
27144    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
27145    .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;}
27146    .btn-secondary:hover{background:var(--line);}
27147    .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;}
27148    .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;}
27149    .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;}
27150    @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));}}
27151    .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;}
27152    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
27153    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
27154    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
27155    .relocate-row{display:flex;gap:8px;align-items:stretch;}
27156    .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;}
27157    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
27158    body.dark-theme .relocate-input{background:var(--surface-2);}
27159  </style>
27160</head>
27161<body>
27162  <div class="background-watermarks" aria-hidden="true">
27163    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27164    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27165    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27166    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27167    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27168    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27169  </div>
27170  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27171  <div class="top-nav">
27172    <div class="top-nav-inner">
27173      <a class="brand" href="/">
27174        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
27175        <div class="brand-copy">
27176          <div class="brand-title">OxideSLOC</div>
27177          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
27178        </div>
27179      </a>
27180      <div class="nav-right">
27181        <a class="nav-pill" href="/">Home</a>
27182        <div class="nav-dropdown">
27183          <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>
27184          <div class="nav-dropdown-menu">
27185            <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>
27186          </div>
27187        </div>
27188        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27189        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27190        <div class="nav-dropdown">
27191          <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>
27192          <div class="nav-dropdown-menu">
27193            <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>
27194          </div>
27195        </div>
27196        <div class="server-status-wrap" id="server-status-wrap">
27197          <div class="nav-pill server-online-pill" id="server-status-pill">
27198            <span class="status-dot" id="status-dot"></span>
27199            <span id="server-status-label">Server</span>
27200            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27201          </div>
27202          <div class="server-status-tip">
27203            OxideSLOC is running — accessible on your network.
27204            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27205          </div>
27206        </div>
27207        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27208          <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>
27209        </button>
27210        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27211          <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>
27212          <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>
27213        </button>
27214      </div>
27215    </div>
27216  </div>
27217
27218  <div class="page">
27219    <div class="panel">
27220      <h1>Scan Files Moved</h1>
27221      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
27222      <div class="error-box" id="relocate-error-box">{{ message }}</div>
27223      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
27224      <div class="relocate-section">
27225        <h2>Locate Scan Output</h2>
27226        <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>
27227        <div class="relocate-row">
27228          <input type="text" id="relocate-folder" name="folder_path"
27229                 value="{{ folder_hint }}"
27230                 placeholder="Path to folder containing scan output..."
27231                 class="relocate-input" autocomplete="off" spellcheck="false">
27232          {% if !server_mode %}
27233          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
27234          {% endif %}
27235        </div>
27236        <div style="margin-top:12px;">
27237          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27238        </div>
27239      </div>
27240      <div class="actions">
27241        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27242        <a class="btn-secondary" href="/view-reports">View Reports</a>
27243      </div>
27244    </div>
27245  </div>
27246  <footer class="site-footer">
27247    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
27248    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27249    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27250    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27251    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27252  </footer>
27253  <script nonce="{{ csp_nonce }}">
27254    (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");});})();
27255    (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);}})();
27256    (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;});})();
27257  </script>
27258  <script nonce="{{ csp_nonce }}">
27259  (function(){
27260    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'}];
27261    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);});}
27262    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27263    function init(){
27264      var btn=document.getElementById('settings-btn');if(!btn)return;
27265      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27266      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>';
27267      document.body.appendChild(m);
27268      var g=document.getElementById('scheme-grid');
27269      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);});
27270      var cl=document.getElementById('settings-close');
27271      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
27272      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');});
27273      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27274      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27275    }
27276    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27277  }());
27278  (function(){
27279    var browseBtn=document.getElementById('browse-relocate-btn');
27280    if(browseBtn){
27281      browseBtn.addEventListener('click',function(){
27282        browseBtn.disabled=true;browseBtn.textContent='...';
27283        var inp=document.getElementById('relocate-folder');
27284        var hint=inp?inp.value:'';
27285        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
27286          .then(function(r){return r.ok?r.json():{cancelled:true};})
27287          .then(function(d){
27288            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27289            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27290          })
27291          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27292      });
27293    }
27294    var restoreBtn=document.getElementById('restore-btn');
27295    var errBox=document.getElementById('relocate-error-box');
27296    var okBox=document.getElementById('relocate-success-box');
27297    if(restoreBtn){
27298      restoreBtn.addEventListener('click',function(){
27299        var inp=document.getElementById('relocate-folder');
27300        var folder=inp?inp.value.trim():'';
27301        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27302        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27303        var body=new URLSearchParams();
27304        body.set('run_id','{{ run_id }}');
27305        body.set('redirect_url','{{ redirect_url }}');
27306        body.set('folder_path',folder);
27307        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27308          .then(function(r){return r.json();})
27309          .then(function(d){
27310            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27311            if(d&&d.ok){
27312              if(errBox)errBox.classList.add('hidden');
27313              if(okBox){okBox.style.display='block';}
27314              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27315            } else {
27316              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27317            }
27318          })
27319          .catch(function(e){
27320            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27321            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27322          });
27323      });
27324    }
27325  }());
27326  </script>
27327  <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]';
27328  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;}
27329  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>
27330</body>
27331</html>
27332"##,
27333    ext = "html"
27334)]
27335struct RelocateScanTemplate {
27336    message: String,
27337    run_id: String,
27338    folder_hint: String,
27339    redirect_url: String,
27340    server_mode: bool,
27341    csp_nonce: String,
27342    version: &'static str,
27343}
27344
27345// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
27346
27347#[derive(Template)]
27348#[template(
27349    source = r##"
27350<!doctype html>
27351<html lang="en">
27352<head>
27353  <meta charset="utf-8">
27354  <meta name="viewport" content="width=device-width, initial-scale=1">
27355  <title>OxideSLOC | View Reports</title>
27356  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27357  <style nonce="{{ csp_nonce }}">
27358    :root {
27359      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27360      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27361      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27362      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27363      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27364    }
27365    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; }
27366    *{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;}
27367    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27368    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27369    .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);}
27370    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27371    .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));}
27372    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27373    .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;}
27374    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27375    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27376    @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; } }
27377    .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;}
27378    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27379    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27380    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27381    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27382    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27383    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
27384    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27385    .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);}
27386    .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;}
27387    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27388    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27389    .settings-modal-body{padding:14px 16px 16px;}
27390    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27391    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27392    .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;}
27393    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27394    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27395    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27396    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27397    .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;}
27398    .tz-select:focus{border-color:var(--oxide);}
27399    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27400    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27401    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27402    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27403    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27404    .panel-meta{font-size:13px;color:var(--muted);}
27405    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27406    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27407    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27408    .per-page-label{font-size:13px;color:var(--muted);}
27409    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;}
27410    .filter-input{min-width:180px;cursor:text;}
27411    .table-wrap{width:100%;overflow-x:auto;}
27412    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27413    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;}
27414    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27415    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27416    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27417    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27418    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27419    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27420    tr:last-child td{border-bottom:none;}
27421    tr:hover td{background:var(--surface-2);}
27422    .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);}
27423    .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);}
27424    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27425    .metric-num{font-weight:700;color:var(--text);}
27426    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27427    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27428    .git-commit-chip{cursor:help;}
27429    .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;}
27430    .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;}
27431    .btn:hover{background:var(--line);}
27432    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27433    .btn.primary:hover{opacity:.9;}
27434    .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;}
27435    .btn-back:hover{background:var(--line);}
27436    .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;}
27437    .export-btn:hover{background:var(--line);}
27438    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27439    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27440    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27441    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27442    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27443    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27444    .pagination-info{font-size:13px;color:var(--muted);}
27445    .pagination-btns{display:flex;gap:6px;}
27446    .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;}
27447    .pg-btn:hover:not(:disabled){background:var(--line);}
27448    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27449    .pg-btn:disabled{opacity:.35;cursor:default;}
27450    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27451    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27452    .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);}
27453    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27454    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27455    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27456    .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);}
27457    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27458    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27459    .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;}
27460    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27461    .site-footer a{color:var(--muted);}
27462    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27463    .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%;}
27464    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27465    .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;}
27466    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27467    .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;}
27468    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27469    .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;}
27470    .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;}
27471    .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;}
27472    @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));}}
27473    .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;}
27474    .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;}
27475    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27476    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27477    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27478    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27479    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27480    .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;}
27481    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27482    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27483    .watched-chip-rm:hover{color:var(--oxide);}
27484    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27485    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27486    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27487    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27488    .rpt-btn{min-width:58px;justify-content:center;}
27489    .flex-row{display:flex;align-items:center;gap:8px;}
27490    .report-cell{overflow:visible;white-space:normal;}
27491    #history-table col:nth-child(1){width:185px;}
27492    #history-table col:nth-child(2){width:220px;}
27493    #history-table col:nth-child(3){width:100px;}
27494    #history-table col:nth-child(4){width:72px;}
27495    #history-table col:nth-child(5){width:82px;}
27496    #history-table col:nth-child(6){width:82px;}
27497    #history-table col:nth-child(7){width:65px;}
27498    #history-table col:nth-child(8){width:90px;}
27499    #history-table col:nth-child(9){width:85px;}
27500    #history-table col:nth-child(10){width:115px;}
27501    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27502    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27503    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27504    .submod-details summary::-webkit-details-marker{display:none;}
27505.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27506    .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;}
27507    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27508    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27509  </style>
27510</head>
27511<body>
27512  <div class="background-watermarks" aria-hidden="true">
27513    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27514    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27515    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27516    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27517    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27518    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27519  </div>
27520  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27521  <div class="top-nav">
27522    <div class="top-nav-inner">
27523      <a class="brand" href="/">
27524        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27525        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27526      </a>
27527      <div class="nav-right">
27528        <a class="nav-pill" href="/">Home</a>
27529        <div class="nav-dropdown">
27530          <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>
27531          <div class="nav-dropdown-menu">
27532            <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>
27533          </div>
27534        </div>
27535        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27536        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27537        <div class="nav-dropdown">
27538          <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>
27539          <div class="nav-dropdown-menu">
27540            <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>
27541          </div>
27542        </div>
27543        <div class="server-status-wrap" id="server-status-wrap">
27544          <div class="nav-pill server-online-pill" id="server-status-pill">
27545            <span class="status-dot" id="status-dot"></span>
27546            <span id="server-status-label">Server</span>
27547            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27548          </div>
27549          <div class="server-status-tip">
27550            OxideSLOC is running — accessible on your network.
27551            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27552          </div>
27553        </div>
27554        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27555          <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>
27556        </button>
27557        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27558          <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>
27559          <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>
27560        </button>
27561      </div>
27562    </div>
27563  </div>
27564
27565  <div class="page">
27566    {% if let Some(err) = browse_error %}
27567    <div class="toast-error">
27568      <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>
27569      {{ err }}
27570    </div>
27571    {% endif %}
27572    {% if linked_count > 0 %}
27573    <div class="toast-success">
27574      <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>
27575      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27576    </div>
27577    {% endif %}
27578    <div class="watched-bar">
27579      <div class="watched-bar-left">
27580        <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>
27581        <span class="watched-label">Watched Folders</span>
27582        <div class="watched-chips">
27583          {% if server_mode %}
27584          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27585          {% else %}
27586          {% for dir in watched_dirs %}
27587          <span class="watched-chip">
27588            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27589            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27590              <input type="hidden" name="folder_path" value="{{ dir }}">
27591              <input type="hidden" name="redirect_to" value="/view-reports">
27592              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27593            </form>
27594          </span>
27595          {% endfor %}
27596          {% if watched_dirs.is_empty() %}
27597          <span class="watched-none">No folders watched — click Choose to add one</span>
27598          {% endif %}
27599          {% endif %}
27600        </div>
27601      </div>
27602      {% if !server_mode %}
27603      <div class="watched-bar-right">
27604        <button type="button" class="btn" id="add-watched-btn">
27605          <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>
27606          Choose
27607        </button>
27608        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27609          <input type="hidden" name="redirect_to" value="/view-reports">
27610          <button type="submit" class="btn">&#8635; Refresh</button>
27611        </form>
27612      </div>
27613      {% endif %}
27614    </div>
27615    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27616      <div class="scan-overlay-card">
27617        <div class="scan-spinner"></div>
27618        <div class="scan-overlay-text">Scanning folder…</div>
27619        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27620      </div>
27621    </div>
27622    <style>
27623    .scan-overlay{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}
27624    .scan-overlay.active{display:flex;}
27625    .scan-overlay-card{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}
27626    .scan-spinner{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}
27627    @keyframes scanSpin{to{transform:rotate(360deg);}}
27628    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27629    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27630    </style>
27631    {% if total_scans > 0 %}
27632    <div class="summary-strip">
27633      <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>
27634      <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>
27635      <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>
27636      <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>
27637    </div>
27638    {% endif %}
27639
27640    <section class="panel">
27641      <div class="panel-header">
27642        <div>
27643          <h1>View Reports</h1>
27644          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27645          {% 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 %}
27646        </div>
27647        <div class="flex-row">
27648          <button type="button" class="export-btn" id="export-csv-btn">
27649            <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>
27650            Export CSV
27651          </button>
27652          <button type="button" class="export-btn" id="export-xls-btn">
27653            <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>
27654            Export Excel
27655          </button>
27656        </div>
27657      </div>
27658
27659      {% if entries.is_empty() %}
27660      <div class="empty-state">
27661        <strong>No reports with viewable HTML yet</strong>
27662        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.
27663      </div>
27664      {% else %}
27665      <div class="filter-row">
27666        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27667        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27668        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27669      </div>
27670      <div class="table-wrap">
27671        <table id="history-table">
27672          <colgroup>
27673            <col><col><col><col><col><col><col><col><col><col>
27674          </colgroup>
27675          <thead>
27676            <tr id="history-thead">
27677              <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>
27678              <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>
27679              <th>Run ID<div class="col-resize-handle"></div></th>
27680              <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>
27681              <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>
27682              <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>
27683              <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>
27684              <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>
27685              <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>
27686              <th>Report<div class="col-resize-handle"></div></th>
27687            </tr>
27688          </thead>
27689          <tbody id="history-tbody">
27690            {% for entry in entries %}
27691            <tr class="history-row" data-run="{{ entry.run_id }}"
27692                data-timestamp="{{ entry.timestamp }}"
27693                data-project="{{ entry.project_label }}"
27694                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27695                data-skipped="{{ entry.files_skipped }}"
27696                data-comments="{{ entry.comment_lines }}"
27697                data-blank="{{ entry.blank_lines }}"
27698                data-physical="{{ entry.total_physical_lines }}"
27699                data-functions="{{ entry.functions }}"
27700                data-classes="{{ entry.classes }}"
27701                data-variables="{{ entry.variables }}"
27702                data-imports="{{ entry.imports }}"
27703                data-tests="{{ entry.test_count }}"
27704                data-branch="{{ entry.git_branch }}"
27705                data-commit="{{ entry.git_commit }}"
27706                data-has-json="{{ entry.has_json }}"
27707                data-html-url="/runs/html/{{ entry.run_id }}">
27708              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27709              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27710              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27711              <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>
27712              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27713              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27714              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27715              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
27716              <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>
27717              <td class="report-cell">
27718                <div class="actions-cell">
27719                  {% 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 %}
27720                  {% 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 %}
27721                </div>
27722                {% if !entry.submodule_links.is_empty() %}
27723                <details class="submod-details">
27724                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
27725                  <div class="submod-link-list">
27726                    {% for sub in entry.submodule_links %}
27727                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27728                    {% endfor %}
27729                  </div>
27730                </details>
27731                {% endif %}
27732              </td>
27733            </tr>
27734            {% endfor %}
27735          </tbody>
27736        </table>
27737      </div>
27738      <div class="pagination">
27739        <span class="pagination-info" id="pagination-info"></span>
27740        <div class="pagination-btns" id="pagination-btns"></div>
27741        <div class="flex-row">
27742          <span class="per-page-label">Show</span>
27743          <select class="per-page" id="per-page-sel">
27744            <option value="10">10 per page</option>
27745            <option value="25" selected>25 per page</option>
27746            <option value="50">50 per page</option>
27747            <option value="100">100 per page</option>
27748          </select>
27749          <span class="per-page-label" id="page-range-label"></span>
27750        </div>
27751      </div>
27752      {% endif %}
27753    </section>
27754  </div>
27755
27756  <footer class="site-footer">
27757    local code analysis - metrics, history and reports
27758    &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>
27759    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27760    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27761    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27762    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27763  </footer>
27764
27765  <script nonce="{{ csp_nonce }}">
27766    (function () {
27767      // ── Theme ──────────────────────────────────────────────────────────────
27768      var storageKey = 'oxide-sloc-theme';
27769      var body = document.body;
27770      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27771      var toggle = document.getElementById('theme-toggle');
27772      if (toggle) toggle.addEventListener('click', function () {
27773        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27774        body.classList.toggle('dark-theme', next === 'dark');
27775        try { localStorage.setItem(storageKey, next); } catch(e) {}
27776      });
27777
27778      // ── State ─────────────────────────────────────────────────────────────
27779      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27780      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27781      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27782
27783      // Aggregate stats from first (most recent) row
27784      if (allRows.length) {
27785        var first = allRows[0];
27786        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();}
27787        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>':'');}
27788        setChipVal('agg-code', first.dataset.code);
27789        setChipVal('agg-files', first.dataset.files);
27790        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27791        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27792        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(); });
27793      }
27794
27795      // ── Branch filter population ──────────────────────────────────────────
27796      (function() {
27797        var branches = {};
27798        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27799        var sel = document.getElementById('branch-filter');
27800        if (sel) Object.keys(branches).sort().forEach(function(b) {
27801          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27802        });
27803      })();
27804
27805      // ── Filter ────────────────────────────────────────────────────────────
27806      function getFilteredRows() {
27807        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27808        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27809        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27810          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27811          if (branch && (r.dataset.branch || '') !== branch) return false;
27812          return true;
27813        });
27814      }
27815
27816      // ── Pagination ────────────────────────────────────────────────────────
27817      function renderPage() {
27818        var filtered = getFilteredRows();
27819        var total = filtered.length;
27820        var totalPages = Math.max(1, Math.ceil(total / perPage));
27821        currentPage = Math.min(currentPage, totalPages);
27822        var start = (currentPage - 1) * perPage;
27823        var end = Math.min(start + perPage, total);
27824        var shown = {};
27825        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27826        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27827          r.style.display = shown[r.dataset.run] ? '' : 'none';
27828        });
27829        var rl = document.getElementById('page-range-label');
27830        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27831        var info = document.getElementById('pagination-info');
27832        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27833        var btns = document.getElementById('pagination-btns');
27834        if (!btns) return;
27835        btns.innerHTML = '';
27836        function makeBtn(lbl, pg, active, disabled) {
27837          var b = document.createElement('button');
27838          b.className = 'pg-btn' + (active ? ' active' : '');
27839          b.textContent = lbl; b.disabled = disabled;
27840          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27841          return b;
27842        }
27843        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27844        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27845        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27846        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27847      }
27848
27849      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27850      window.applyFilters = function() { currentPage = 1; renderPage(); };
27851
27852      // ── Sorting ───────────────────────────────────────────────────────────
27853      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27854      function doSort(col, type, order) {
27855        var tbody = document.getElementById('history-tbody');
27856        if (!tbody) return;
27857        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27858        rows.sort(function(a, b) {
27859          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27860          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27861          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27862          return va < vb ? 1 : va > vb ? -1 : 0;
27863        });
27864        rows.forEach(function(r) { tbody.appendChild(r); });
27865        currentPage = 1; renderPage();
27866      }
27867      sortHeaders.forEach(function(th) {
27868        th.addEventListener('click', function(e) {
27869          if (e.target.classList.contains('col-resize-handle')) return;
27870          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27871          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27872          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27873          th.classList.add('sort-' + sortOrder);
27874          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27875          doSort(col, type, sortOrder);
27876        });
27877      });
27878
27879      // ── Column resize ─────────────────────────────────────────────────────
27880      (function() {
27881        var table = document.getElementById('history-table');
27882        if (!table) return;
27883        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27884        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27885        ths.forEach(function(th, i) {
27886          var handle = th.querySelector('.col-resize-handle');
27887          if (!handle || !cols[i]) return;
27888          var startX, startW;
27889          handle.addEventListener('mousedown', function(e) {
27890            e.stopPropagation(); e.preventDefault();
27891            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27892            handle.classList.add('dragging');
27893            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27894            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27895            document.addEventListener('mousemove', onMove);
27896            document.addEventListener('mouseup', onUp);
27897          });
27898        });
27899      })();
27900
27901      // ── Full-commit hover tooltip ─────────────────────────────────────────
27902      // The commit chips live inside an overflow:auto table wrapper, which would
27903      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27904      // (escaping the scroll container) and follow the cursor. Event delegation
27905      // keeps it working after pagination/sorting re-renders the rows.
27906      (function() {
27907        var tip = document.createElement('div');
27908        tip.className = 'commit-tip';
27909        tip.setAttribute('role', 'tooltip');
27910        document.body.appendChild(tip);
27911        var shown = false;
27912        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27913        function place(e) {
27914          var pad = 14, r = tip.getBoundingClientRect();
27915          var x = e.clientX + pad, y = e.clientY + pad;
27916          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27917          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27918          tip.style.left = x + 'px'; tip.style.top = y + 'px';
27919        }
27920        function hide() { tip.style.display = 'none'; shown = false; }
27921        document.addEventListener('mouseover', function(e) {
27922          var chip = chipFrom(e.target);
27923          if (!chip) return;
27924          var full = chip.getAttribute('data-full-commit');
27925          if (!full) return;
27926          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27927        });
27928        document.addEventListener('mousemove', function(e) {
27929          if (!shown) return;
27930          if (chipFrom(e.target)) place(e); else hide();
27931        });
27932        document.addEventListener('mouseout', function(e) {
27933          if (chipFrom(e.target)) hide();
27934        });
27935      })();
27936
27937      // ── Reset view ────────────────────────────────────────────────────────
27938      window.resetView = function() {
27939        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27940        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27941        sortCol = null; sortOrder = 'asc';
27942        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27943        var tbody = document.getElementById('history-tbody');
27944        if (tbody) {
27945          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27946          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27947          rows.forEach(function(r) { tbody.appendChild(r); });
27948        }
27949        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27950        var table = document.getElementById('history-table');
27951        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27952        currentPage = 1; renderPage();
27953      };
27954
27955      renderPage();
27956
27957      // ── Export helpers ────────────────────────────────────────────────────
27958      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
27959      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27960      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);}
27961      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;');}
27962      function slocXlsx(fname,sheet,hdrs,rows){
27963        var enc=new TextEncoder();
27964        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;}
27965        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;}
27966        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27967        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27968        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27969        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;}
27970        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27971        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];}
27972        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27973        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27974        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27975          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27976          +'<fonts count="2">'
27977            +'<font><sz val="11"/><name val="Calibri"/></font>'
27978            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27979          +'</fonts>'
27980          +'<fills count="3">'
27981            +'<fill><patternFill patternType="none"/></fill>'
27982            +'<fill><patternFill patternType="gray125"/></fill>'
27983            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27984          +'</fills>'
27985          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27986          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27987          +'<cellXfs count="4">'
27988            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27989            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27990            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27991            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27992          +'</cellXfs>'
27993          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27994          +'</styleSheet>';
27995        var rx='<row r="1">';
27996        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27997        rx+='</row>';
27998        rows.forEach(function(row,ri){
27999          var rn=ri+2;rx+='<row r="'+rn+'">';
28000          row.forEach(function(cell,c){
28001            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
28002            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
28003            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
28004            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
28005            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
28006            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
28007          });
28008          rx+='</row>';
28009        });
28010        var lastCol=hdrs.length,lastRow=rows.length+1;
28011        var tableRef='A1:'+colNm(lastCol)+lastRow;
28012        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28013          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
28014          +'<autoFilter ref="'+tableRef+'"/>'
28015          +'<tableColumns count="'+lastCol+'">'
28016          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28017          +'</tableColumns>'
28018          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28019          +'</table>';
28020        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28021          +'<Relationships xmlns="'+pns+'relationships">'
28022          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
28023          +'</Relationships>';
28024        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>';
28025        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28026          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28027          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
28028          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
28029          +'</worksheet>';
28030        var F={
28031          '[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>',
28032          '_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>',
28033          '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>',
28034          '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>',
28035          'xl/styles.xml':stl,
28036          'xl/sharedStrings.xml':ssXml,
28037          'xl/worksheets/sheet1.xml':sh,
28038          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
28039          'xl/tables/table1.xml':tableXml
28040        };
28041        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'];
28042        var zparts=[],zcds=[],zoff=0,znf=0;
28043        order.forEach(function(name){
28044          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
28045          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]);
28046          var entry=new Uint8Array(lha.length+nb.length+sz);
28047          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
28048          zparts.push(entry);
28049          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));
28050          var cde=new Uint8Array(cda.length+nb.length);
28051          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
28052          zcds.push(cde);zoff+=entry.length;znf++;
28053        });
28054        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28055        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]);
28056        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
28057        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28058        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28059        zout.set(new Uint8Array(ea),zpos);
28060        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28061      }
28062
28063      // Multi-sheet XLSX builder for the scan-history export.
28064      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
28065      function slocXlsxMulti(fname,sheets){
28066        var enc=new TextEncoder();
28067        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;}
28068        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;}
28069        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
28070        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
28071        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
28072        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];}
28073        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;}
28074        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
28075        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
28076        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
28077          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
28078          +'<fonts count="3">'
28079            +'<font><sz val="11"/><name val="Calibri"/></font>'
28080            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
28081            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
28082          +'</fonts>'
28083          +'<fills count="4">'
28084            +'<fill><patternFill patternType="none"/></fill>'
28085            +'<fill><patternFill patternType="gray125"/></fill>'
28086            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
28087            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
28088          +'</fills>'
28089          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
28090          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
28091          +'<cellXfs count="7">'
28092            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
28093            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28094            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
28095            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28096            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
28097            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
28098            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
28099          +'</cellXfs>'
28100          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
28101          +'</styleSheet>';
28102        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
28103        sheets.forEach(function(sh,sheetIdx){
28104          var rx='<row r="1">';
28105          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
28106          rx+='</row>';
28107          var rn=2;
28108          sh.rows.forEach(function(row){
28109            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
28110            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
28111              rx+='<row r="'+rn+'">';
28112              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
28113              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
28114              rx+='</row>';rn++;return;
28115            }
28116            rx+='<row r="'+rn+'">';
28117            row.forEach(function(cell,c){
28118              var ref=colRef(c,rn);
28119              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
28120              if(typeof cell==='object'&&cell!==null){
28121                var cv=cell.v,cs=cell.s!=null?cell.s:0;
28122                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
28123                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
28124                return;
28125              }
28126              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
28127              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
28128            });
28129            rx+='</row>';rn++;
28130          });
28131          var cw='';
28132          if(sh.colWidths&&sh.colWidths.length>0){
28133            cw='<cols>';
28134            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
28135            cw+='</cols>';
28136          }
28137          var tblParts='';
28138          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
28139            tableCounter++;
28140            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
28141            var tRef='A1:'+colNm(colCount)+rowCount;
28142            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28143              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
28144              +'<autoFilter ref="'+tRef+'"/>'
28145              +'<tableColumns count="'+colCount+'">'
28146              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28147              +'</tableColumns>'
28148              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28149              +'</table>';
28150            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28151              +'<Relationships xmlns="'+pns+'relationships">'
28152              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
28153              +'</Relationships>';
28154            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
28155          }
28156          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28157            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28158            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
28159        });
28160        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>';
28161        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
28162        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
28163        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>';
28164        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
28165        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
28166        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
28167        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
28168          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
28169        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
28170        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};
28171        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
28172        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
28173        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
28174        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
28175        var zparts=[],zcds=[],zoff=0,znf=0;
28176        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++;});
28177        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28178        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]);
28179        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
28180        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28181        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28182        zout.set(new Uint8Array(ea),zpos);
28183        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28184      }
28185
28186      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'};
28187      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
28188
28189      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'];
28190      function getHistoryRows(){
28191        var r=[];
28192        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
28193          var code=Number(tr.getAttribute('data-code'))||0;
28194          var phys=Number(tr.getAttribute('data-physical'))||0;
28195          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28196          r.push([
28197            tr.getAttribute('data-timestamp')||'',
28198            tr.getAttribute('data-project')||'',
28199            tr.getAttribute('data-run')||'',
28200            tr.getAttribute('data-physical')||'',
28201            tr.getAttribute('data-code')||'',
28202            tr.getAttribute('data-comments')||'',
28203            tr.getAttribute('data-blank')||'',
28204            tr.getAttribute('data-files')||'',
28205            tr.getAttribute('data-skipped')||'',
28206            tr.getAttribute('data-functions')||'',
28207            tr.getAttribute('data-classes')||'',
28208            tr.getAttribute('data-variables')||'',
28209            tr.getAttribute('data-imports')||'',
28210            tr.getAttribute('data-tests')||'',
28211            dens,
28212            tr.getAttribute('data-branch')||'',
28213            tr.getAttribute('data-commit')||''
28214          ]);
28215        });
28216        return r;
28217      }
28218      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
28219      window.exportHistoryXls = function(){
28220        var histRows=getHistoryRows();
28221        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
28222        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]];});
28223        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]};
28224        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
28225        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
28226        var runId=jsonRow.getAttribute('data-run')||'';
28227        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
28228        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
28229        fetch('/runs/json/'+runId)
28230          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
28231          .then(function(run){
28232            var tot=run.summary_totals||{};
28233            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28234            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28235            function B(v){return{v:v,s:4};}
28236            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28237            var sumRows=[
28238              [{_sec:true,v:'RUN INFORMATION'}],
28239              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28240              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28241              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28242              [B('Branch'),run.git_branch||''],
28243              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28244              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28245              [B('Files Analyzed'),N(tot.files_analyzed)],
28246              [B('Files Skipped'),N(tot.files_skipped)],
28247              [],
28248              [{_sec:true,v:'CODE METRICS'}],
28249              [B('Physical Lines'),N(phys)],
28250              [B('Code Lines'),N(code)],
28251              [B('Comments'),N(tot.comment_lines)],
28252              [B('Blank Lines'),N(tot.blank_lines)],
28253              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28254              [B('Functions'),N(tot.functions)],
28255              [B('Classes / Types'),N(tot.classes)],
28256              [B('Variables'),N(tot.variables)],
28257              [B('Imports'),N(tot.imports)],
28258              [B('Tests'),N(tot.test_count)],
28259              [B('Assertions'),N(tot.test_assertion_count)],
28260              [B('Test Suites'),N(tot.test_suite_count)],
28261              [B('Code Density'),{v:dens,s:6}],
28262              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28263            ];
28264            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28265            var langRows=(run.totals_by_language||[]).map(function(l){
28266              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28267              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28268              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];
28269            });
28270            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28271            var pfRows=(run.per_file_records||[]).map(function(r){
28272              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28273              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];
28274            });
28275            var skHdrs=['File','Status','Size (bytes)'];
28276            var skRows=(run.skipped_file_records||[]).map(function(r){
28277              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28278            });
28279            slocXlsxMulti('scan-history.xlsx',[
28280              histSheet,
28281              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28282              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28283              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28284              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28285            ]);
28286          })
28287          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28288      };
28289
28290      var csvBtn = document.getElementById('export-csv-btn');
28291      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28292      var xlsBtn = document.getElementById('export-xls-btn');
28293      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28294
28295      // ── Remaining CSP-safe event bindings ────────────────────────────────
28296      (function wireEvents() {
28297        var el;
28298        el = document.getElementById('reset-view-btn');
28299        if (el) el.addEventListener('click', window.resetView);
28300        el = document.getElementById('project-filter');
28301        if (el) el.addEventListener('input', window.applyFilters);
28302        el = document.getElementById('branch-filter');
28303        if (el) el.addEventListener('change', window.applyFilters);
28304        el = document.getElementById('per-page-sel');
28305        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28306        (function(){
28307          window.__scanOverlay=function(msg){var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');};
28308          document.addEventListener('submit',function(e){var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){window.__scanOverlay('Updating watched folders');}else if(a.indexOf('/watched-dirs/')!==-1){window.__scanOverlay();}},true);
28309        })();
28310        el = document.getElementById('add-watched-btn');
28311        if (el) el.addEventListener('click', function() {
28312          fetch('/pick-directory?kind=reports')
28313            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28314            .then(function(data) {
28315              if (!data.cancelled && data.selected_path) {
28316                var form = document.createElement('form');
28317                form.method = 'POST';
28318                form.action = '/watched-dirs/add';
28319                var ri = document.createElement('input');
28320                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28321                var fi = document.createElement('input');
28322                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28323                form.appendChild(ri); form.appendChild(fi);
28324                document.body.appendChild(form);
28325                if (window.__scanOverlay) window.__scanOverlay();
28326                form.submit();
28327              }
28328            })
28329            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28330        });
28331      })();
28332
28333      (function randomizeWatermarks() {
28334        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28335        if (!wms.length) return;
28336        var placed = [];
28337        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;}
28338        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];}
28339        var half=Math.floor(wms.length/2);
28340        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;});
28341      })();
28342
28343      (function spawnCodeParticles() {
28344        var container = document.getElementById('code-particles');
28345        if (!container) return;
28346        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'];
28347        for (var i = 0; i < 38; i++) {
28348          (function(idx) {
28349            var el = document.createElement('span');
28350            el.className = 'code-particle';
28351            el.textContent = snippets[idx % snippets.length];
28352            var left = Math.random() * 94 + 2;
28353            var top = Math.random() * 88 + 6;
28354            var dur = (Math.random() * 10 + 9).toFixed(1);
28355            var delay = (Math.random() * 18).toFixed(1);
28356            var rot = (Math.random() * 26 - 13).toFixed(1);
28357            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28358            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';
28359            container.appendChild(el);
28360          })(i);
28361        }
28362      })();
28363    })();
28364  </script>
28365  <script nonce="{{ csp_nonce }}">
28366  (function(){
28367    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'}];
28368    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);});}
28369    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28370    function init(){
28371      var btn=document.getElementById('settings-btn');if(!btn)return;
28372      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28373      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>';
28374      document.body.appendChild(m);
28375      var g=document.getElementById('scheme-grid');
28376      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);});
28377      var cl=document.getElementById('settings-close');
28378      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
28379      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');});
28380      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28381      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28382    }
28383    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28384  }());
28385  </script>
28386  <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>
28387</body>
28388</html>
28389"##,
28390    ext = "html"
28391)]
28392struct HistoryTemplate {
28393    version: &'static str,
28394    entries: Vec<HistoryEntryRow>,
28395    total_scans: usize,
28396    linked_count: usize,
28397    browse_error: Option<String>,
28398    watched_dirs: Vec<String>,
28399    csp_nonce: String,
28400    server_mode: bool,
28401}
28402
28403// ── CompareSelectTemplate ──────────────────────────────────────────────────────
28404
28405#[derive(Template)]
28406#[template(
28407    source = r##"
28408<!doctype html>
28409<html lang="en">
28410<head>
28411  <meta charset="utf-8">
28412  <meta name="viewport" content="width=device-width, initial-scale=1">
28413  <title>OxideSLOC | Compare Scans</title>
28414  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28415  <style nonce="{{ csp_nonce }}">
28416    :root {
28417      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28418      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28419      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28420      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28421      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28422    }
28423    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28424    *{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;}
28425    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28426    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28427    .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);}
28428    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28429    .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));}
28430    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28431    .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;}
28432    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28433    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28434    @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; } }
28435    .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;}
28436    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28437    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28438    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28439    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28440    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28441    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
28442    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28443    .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);}
28444    .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;}
28445    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28446    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28447    .settings-modal-body{padding:14px 16px 16px;}
28448    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28449    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28450    .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;}
28451    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28452    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28453    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28454    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28455    .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;}
28456    .tz-select:focus{border-color:var(--oxide);}
28457    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28458    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28459    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28460    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28461    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28462    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28463    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28464    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28465    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28466    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28467    .per-page-label{font-size:13px;color:var(--muted);}
28468    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;}
28469    .filter-input{min-width:180px;cursor:text;}
28470    .table-wrap{width:100%;overflow-x:auto;}
28471    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28472    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;}
28473    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28474    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28475    #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;}
28476    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28477    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28478    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28479    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28480    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28481    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28482    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28483    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28484    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28485    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28486    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28487    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28488    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28489    tr:last-child td{border-bottom:none;}
28490    tr.selected td{background:var(--sel-bg);}
28491    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28492    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28493    tr{cursor:pointer;}
28494    tr.row-locked{opacity:.35;cursor:not-allowed;}
28495    tr.row-locked td{pointer-events:none;}
28496    .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;}
28497    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28498    .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;}
28499    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28500    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28501    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28502    .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);}
28503    .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);}
28504    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28505    .metric-num{font-weight:700;color:var(--text);}
28506    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28507    .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;}
28508    .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;}
28509    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28510    .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;}
28511    .btn:hover{background:var(--line);}
28512    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28513    .btn.primary:hover{opacity:.9;}
28514    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28515    .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;}
28516    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28517    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28518    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28519    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28520    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28521    .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;}
28522    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28523    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28524    .watched-chip-rm:hover{color:var(--oxide);}
28525    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28526    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28527    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28528    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28529    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28530    .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;}
28531    .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;}
28532    .btn-back:hover{background:var(--line);}
28533    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28534    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28535    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28536    .pagination-info{font-size:13px;color:var(--muted);}
28537    .pagination-btns{display:flex;gap:6px;}
28538    .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;}
28539    .pg-btn:hover:not(:disabled){background:var(--line);}
28540    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28541    .pg-btn:disabled{opacity:.35;cursor:default;}
28542    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28543    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28544    .site-footer a{color:var(--muted);}
28545    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28546    .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;}
28547    .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;}
28548    .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;}
28549    @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));}}
28550    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28551    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28552    .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);}
28553    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28554    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28555    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28556    .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);}
28557    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28558    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28559    .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;}
28560    .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;}
28561    .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%;}
28562    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28563    .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;}
28564    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28565    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28566    .hidden{display:none!important;}
28567    .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%;}
28568    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28569    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28570    .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;}
28571    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28572    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28573    .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;}
28574    .scope-option:hover{background:var(--line);}
28575    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28576    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28577    .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;}
28578    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28579    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28580    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28581    .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;}
28582  </style>
28583</head>
28584<body>
28585  <div class="background-watermarks" aria-hidden="true">
28586    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28587    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28588    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28589    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28590    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28591    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28592  </div>
28593  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28594  <div class="top-nav">
28595    <div class="top-nav-inner">
28596      <a class="brand" href="/">
28597        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28598        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28599      </a>
28600      <div class="nav-right">
28601        <a class="nav-pill" href="/">Home</a>
28602        <div class="nav-dropdown">
28603          <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>
28604          <div class="nav-dropdown-menu">
28605            <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>
28606          </div>
28607        </div>
28608        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28609        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28610        <div class="nav-dropdown">
28611          <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>
28612          <div class="nav-dropdown-menu">
28613            <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>
28614          </div>
28615        </div>
28616        <div class="server-status-wrap" id="server-status-wrap">
28617          <div class="nav-pill server-online-pill" id="server-status-pill">
28618            <span class="status-dot" id="status-dot"></span>
28619            <span id="server-status-label">Server</span>
28620            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28621          </div>
28622          <div class="server-status-tip">
28623            OxideSLOC is running — accessible on your network.
28624            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28625          </div>
28626        </div>
28627        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28628          <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>
28629        </button>
28630        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28631          <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>
28632          <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>
28633        </button>
28634      </div>
28635    </div>
28636  </div>
28637
28638  <div class="page">
28639    <div class="watched-bar">
28640      <div class="watched-bar-left">
28641        <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>
28642        <span class="watched-label">Watched Folders</span>
28643        <div class="watched-chips">
28644          {% if server_mode %}
28645          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28646          {% else %}
28647          {% for dir in watched_dirs %}
28648          <span class="watched-chip">
28649            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28650            <form method="POST" action="/watched-dirs/remove" style="display:contents">
28651              <input type="hidden" name="folder_path" value="{{ dir }}">
28652              <input type="hidden" name="redirect_to" value="/compare-scans">
28653              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
28654            </form>
28655          </span>
28656          {% endfor %}
28657          {% if watched_dirs.is_empty() %}
28658          <span class="watched-none">No folders watched — click Choose to add one</span>
28659          {% endif %}
28660          {% endif %}
28661        </div>
28662      </div>
28663      {% if !server_mode %}
28664      <div class="watched-bar-right">
28665        <button type="button" class="btn" id="add-watched-btn">
28666          <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>
28667          Choose
28668        </button>
28669        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28670          <input type="hidden" name="redirect_to" value="/compare-scans">
28671          <button type="submit" class="btn">&#8635; Refresh</button>
28672        </form>
28673      </div>
28674      {% endif %}
28675    </div>
28676    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28677      <div class="scan-overlay-card">
28678        <div class="scan-spinner"></div>
28679        <div class="scan-overlay-text">Scanning folder…</div>
28680        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28681      </div>
28682    </div>
28683    <style>
28684    .scan-overlay{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}
28685    .scan-overlay.active{display:flex;}
28686    .scan-overlay-card{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}
28687    .scan-spinner{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}
28688    @keyframes scanSpin{to{transform:rotate(360deg);}}
28689    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28690    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28691    </style>
28692    {% if total_scans > 0 %}
28693    <div class="summary-strip">
28694      <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>
28695      <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>
28696      <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>
28697      <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>
28698    </div>
28699    {% endif %}
28700    <section class="panel">
28701      <div class="panel-header">
28702        <div>
28703          <h1>Compare Scans</h1>
28704          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28705        </div>
28706        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28707          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28708            <button class="btn primary" id="compare-btn" disabled>
28709              <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>
28710              Compare <span class="sel-count" id="sel-count">0</span> Selected
28711            </button>
28712          </div>
28713        </div>
28714      </div>
28715
28716      {% if entries.is_empty() %}
28717      <div class="empty-state">
28718        <strong>No scans yet</strong>
28719        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.
28720      </div>
28721      {% else %}
28722      <div class="filter-row">
28723        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
28724        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28725        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
28726      </div>
28727      <div class="scope-panel hidden" id="scope-panel">
28728        <div class="scope-panel-label">
28729          <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>
28730          Compare scope — choose what to include
28731        </div>
28732        <div class="scope-options" id="scope-options"></div>
28733      </div>
28734      {% if total_scans > 0 %}
28735      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28736        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28737          <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>
28738          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28739        </div>
28740      </div>
28741      {% endif %}
28742      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28743        <span class="compare-all-label">
28744          <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>
28745          Quick Compare All
28746        </span>
28747      </div>
28748      <div class="table-wrap">
28749        <table id="compare-table">
28750          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28751          <thead>
28752            <tr id="compare-thead">
28753              <th><div class="col-resize-handle"></div></th>
28754              <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>
28755              <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>
28756              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28757              <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>
28758              <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>
28759              <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>
28760              <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>
28761              <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>
28762              <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>
28763              <th>Submodules<div class="col-resize-handle"></div></th>
28764            </tr>
28765          </thead>
28766          <tbody id="compare-tbody">
28767            {% for entry in entries %}
28768            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28769                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28770                data-project="{{ entry.project_label }}"
28771                data-files="{{ entry.files_analyzed }}"
28772                data-code="{{ entry.code_lines }}"
28773                data-comments="{{ entry.comment_lines }}"
28774                data-blank="{{ entry.blank_lines }}"
28775                data-branch="{{ entry.git_branch }}"
28776                data-commit="{{ entry.git_commit }}"
28777                data-submodules="{{ entry.submodule_names_csv }}">
28778              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28779              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28780              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28781              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28782              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28783              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28784              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28785              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28786              <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>
28787              <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>
28788              <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>
28789            </tr>
28790            {% endfor %}
28791          </tbody>
28792        </table>
28793      </div>
28794      <div class="pagination">
28795        <span class="pagination-info" id="pagination-info"></span>
28796        <div class="pagination-btns" id="pagination-btns"></div>
28797        <div class="flex-row">
28798          <span class="per-page-label">Show</span>
28799          <select class="per-page" id="per-page-sel">
28800            <option value="10">10 per page</option>
28801            <option value="25" selected>25 per page</option>
28802            <option value="50">50 per page</option>
28803            <option value="100">100 per page</option>
28804          </select>
28805          <span class="per-page-label" id="page-range-label"></span>
28806        </div>
28807      </div>
28808      {% endif %}
28809    </section>
28810  </div>
28811
28812  <footer class="site-footer">
28813    local code analysis - metrics, history and reports
28814    &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>
28815    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28816    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28817    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28818    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28819  </footer>
28820
28821  <script nonce="{{ csp_nonce }}">
28822    (function () {
28823      // ── Theme ──────────────────────────────────────────────────────────────
28824      var storageKey = 'oxide-sloc-theme';
28825      var body = document.body;
28826      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28827      var toggle = document.getElementById('theme-toggle');
28828      if (toggle) toggle.addEventListener('click', function () {
28829        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28830        body.classList.toggle('dark-theme', next === 'dark');
28831        try { localStorage.setItem(storageKey, next); } catch(e) {}
28832      });
28833
28834      // ── State ─────────────────────────────────────────────────────────────
28835      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28836      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28837      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28838      window._allCompareRows = allRows;
28839
28840      // ── Stat chips ────────────────────────────────────────────────────────
28841      (function() {
28842        var projects = {}, latestTs = '', latestRow = null;
28843        allRows.forEach(function(r) {
28844          var p = r.dataset.project || ''; if (p) projects[p] = true;
28845          var ts = r.dataset.timestamp || '';
28846          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28847        });
28848        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();}
28849        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>':'');}
28850        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28851        if (latestRow) {
28852          setChipVal('agg-code', latestRow.dataset.code);
28853          setChipVal('agg-files', latestRow.dataset.files);
28854        }
28855        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(); });
28856      })();
28857
28858      // ── Branch filter population ──────────────────────────────────────────
28859      (function() {
28860        var branches = {};
28861        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28862        var sel = document.getElementById('branch-filter');
28863        if (sel) Object.keys(branches).sort().forEach(function(b) {
28864          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28865        });
28866      })();
28867
28868      // ── Filter ────────────────────────────────────────────────────────────
28869      function getFilteredRows() {
28870        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28871        var branch = ((document.getElementById('branch-filter') || {}).value || '');
28872        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28873          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28874          if (branch && (r.dataset.branch || '') !== branch) return false;
28875          return true;
28876        });
28877      }
28878
28879      // ── Pagination ────────────────────────────────────────────────────────
28880      function renderPage() {
28881        var filtered = getFilteredRows();
28882        var total = filtered.length;
28883        var totalPages = Math.max(1, Math.ceil(total / perPage));
28884        currentPage = Math.min(currentPage, totalPages);
28885        var start = (currentPage - 1) * perPage;
28886        var end = Math.min(start + perPage, total);
28887        var shown = {};
28888        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28889        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28890          r.style.display = shown[r.dataset.run] ? '' : 'none';
28891        });
28892        var rl = document.getElementById('page-range-label');
28893        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28894        var info = document.getElementById('pagination-info');
28895        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28896        var btns = document.getElementById('pagination-btns');
28897        if (!btns) return;
28898        btns.innerHTML = '';
28899        function makeBtn(lbl, pg, active, disabled) {
28900          var b = document.createElement('button');
28901          b.className = 'pg-btn' + (active ? ' active' : '');
28902          b.textContent = lbl; b.disabled = disabled;
28903          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28904          return b;
28905        }
28906        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28907        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28908        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28909        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28910      }
28911
28912      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28913      window.applyFilters = function() { currentPage = 1; renderPage(); };
28914
28915      // ── Sorting ───────────────────────────────────────────────────────────
28916      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28917      function doSort(col, type, order) {
28918        var tbody = document.getElementById('compare-tbody');
28919        if (!tbody) return;
28920        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28921        rows.sort(function(a, b) {
28922          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28923          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28924          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28925          return va < vb ? 1 : va > vb ? -1 : 0;
28926        });
28927        rows.forEach(function(r) { tbody.appendChild(r); });
28928        currentPage = 1; renderPage();
28929      }
28930      sortHeaders.forEach(function(th) {
28931        th.addEventListener('click', function(e) {
28932          if (e.target.classList.contains('col-resize-handle')) return;
28933          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28934          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28935          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28936          th.classList.add('sort-' + sortOrder);
28937          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28938          doSort(col, type, sortOrder);
28939        });
28940      });
28941
28942      // Apply default sort (timestamp desc) on initial load
28943      (function() {
28944        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28945        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28946      })();
28947
28948      // ── Column resize ─────────────────────────────────────────────────────
28949      (function() {
28950        var table = document.getElementById('compare-table');
28951        if (!table) return;
28952        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28953        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28954        ths.forEach(function(th, i) {
28955          var handle = th.querySelector('.col-resize-handle');
28956          if (!handle || !cols[i]) return;
28957          var startX, startW;
28958          handle.addEventListener('mousedown', function(e) {
28959            e.stopPropagation(); e.preventDefault();
28960            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28961            handle.classList.add('dragging');
28962            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28963            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28964            document.addEventListener('mousemove', onMove);
28965            document.addEventListener('mouseup', onUp);
28966          });
28967        });
28968      })();
28969
28970      // ── Full-commit hover tooltip ─────────────────────────────────────────
28971      // The commit chips live inside an overflow:auto table wrapper, which would
28972      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28973      // (escaping the scroll container) and follow the cursor. Event delegation
28974      // keeps it working after pagination/sorting re-renders the rows.
28975      (function() {
28976        var tip = document.createElement('div');
28977        tip.className = 'commit-tip';
28978        tip.setAttribute('role', 'tooltip');
28979        document.body.appendChild(tip);
28980        var shown = false;
28981        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28982        function place(e) {
28983          var pad = 14, r = tip.getBoundingClientRect();
28984          var x = e.clientX + pad, y = e.clientY + pad;
28985          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28986          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28987          tip.style.left = x + 'px'; tip.style.top = y + 'px';
28988        }
28989        function hide() { tip.style.display = 'none'; shown = false; }
28990        document.addEventListener('mouseover', function(e) {
28991          var chip = chipFrom(e.target);
28992          if (!chip) return;
28993          var full = chip.getAttribute('data-full-commit');
28994          if (!full) return;
28995          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28996        });
28997        document.addEventListener('mousemove', function(e) {
28998          if (!shown) return;
28999          if (chipFrom(e.target)) place(e); else hide();
29000        });
29001        document.addEventListener('mouseout', function(e) {
29002          if (chipFrom(e.target)) hide();
29003        });
29004      })();
29005
29006      // ── Reset view ────────────────────────────────────────────────────────
29007      window.resetView = function() {
29008        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
29009        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
29010        sortCol = null; sortOrder = 'asc';
29011        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29012        var tbody = document.getElementById('compare-tbody');
29013        if (tbody) {
29014          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
29015          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
29016          rows.forEach(function(r) { tbody.appendChild(r); });
29017        }
29018        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
29019        var table = document.getElementById('compare-table');
29020        currentPage = 1; renderPage();
29021        currentPage = 1; renderPage();
29022      };
29023
29024      renderPage();
29025      buildCompareAllBar();
29026
29027      // ── Row selection state ───────────────────────────────────────────────
29028      var selected = [];
29029      var lockedProject = null; // project label of first selected scan
29030
29031      function updateCompareBtn() {
29032        var btn = document.getElementById('compare-btn');
29033        var cnt = document.getElementById('sel-count');
29034        if (!btn) return;
29035        btn.disabled = selected.length < 2;
29036        if (cnt) cnt.textContent = selected.length;
29037      }
29038
29039      function applyProjectLock() {
29040        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29041        allRows.forEach(function(r) {
29042          if (lockedProject === null) {
29043            r.classList.remove('row-locked');
29044          } else {
29045            var proj = r.dataset.project || '';
29046            if (proj !== lockedProject) {
29047              r.classList.add('row-locked');
29048            } else {
29049              r.classList.remove('row-locked');
29050            }
29051          }
29052        });
29053      }
29054
29055      function toggleRow(row) {
29056        if (row.classList.contains('row-locked')) return;
29057        var vid = row.dataset.vid || row.dataset.run;
29058        var idx = selected.indexOf(vid);
29059        if (idx >= 0) {
29060          selected.splice(idx, 1);
29061          row.classList.remove('selected');
29062          var b = document.getElementById('badge-' + vid);
29063          if (b) b.textContent = '';
29064          // Release project lock if nothing selected
29065          if (selected.length === 0) lockedProject = null;
29066        } else {
29067          // Set project lock on first selection
29068          if (selected.length === 0) lockedProject = row.dataset.project || null;
29069          selected.push(vid);
29070          row.classList.add('selected');
29071        }
29072        selected.forEach(function(v, i) {
29073          var b = document.getElementById('badge-' + v);
29074          if (b) b.textContent = i + 1;
29075        });
29076        applyProjectLock();
29077        updateCompareBtn();
29078        buildScopePanel();
29079      }
29080
29081      // ── Compare-All bar ───────────────────────────────────────────────────
29082      function buildCompareAllBar() {
29083        var bar = document.getElementById('compare-all-bar');
29084        if (!bar) return;
29085        // Group all rows by project label.
29086        var groups = {};
29087        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29088        // Use all rows from the source data (not just visible).
29089        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29090        // We need ALL rows across all pages, not just the rendered ones.
29091        // Use the underlying allRows array that the pagination JS also uses.
29092        var sourceRows = window._allCompareRows || allRowsAll;
29093        sourceRows.forEach(function(r) {
29094          var proj = r.dataset.project || '';
29095          var vid = r.dataset.vid || r.dataset.run || '';
29096          if (!proj || !vid) return;
29097          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
29098          groups[proj].ids.push(vid);
29099          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
29100        });
29101        // Build buttons for each project with >= 2 scans.
29102        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
29103        if (!keys.length) { bar.style.display = 'none'; return; }
29104        bar.style.display = 'flex';
29105        // Remove old buttons (keep label).
29106        var oldBtns = bar.querySelectorAll('.compare-all-btn');
29107        oldBtns.forEach(function(b) { b.remove(); });
29108        keys.sort();
29109        keys.forEach(function(proj) {
29110          var g = groups[proj];
29111          var btn = document.createElement('button');
29112          btn.className = 'compare-all-btn';
29113          btn.type = 'button';
29114          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
29115          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
29116          btn.addEventListener('click', function() {
29117            // Sort ids by timestamp (ascending).
29118            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
29119            pairs.sort(function(a, b) { return a.ts - b.ts; });
29120            var sorted = pairs.map(function(p) { return p.id; });
29121            if (sorted.length === 2) {
29122              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
29123            } else {
29124              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
29125            }
29126          });
29127          bar.appendChild(btn);
29128        });
29129      }
29130
29131      // ── Scope panel ───────────────────────────────────────────────────────
29132      var selectedScope = 'all';
29133
29134      function buildScopePanel() {
29135        var panel = document.getElementById('scope-panel');
29136        var opts = document.getElementById('scope-options');
29137        if (!panel || !opts) return;
29138        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29139
29140        // Collect union of submodules from all selected rows.
29141        var allSubs = {};
29142        selected.forEach(function(vid) {
29143          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
29144          if (!row) return;
29145          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
29146        });
29147        var subList = Object.keys(allSubs).sort();
29148        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29149
29150        panel.classList.remove('hidden');
29151        opts.innerHTML = '';
29152
29153        function makeOption(value, label, title) {
29154          var div = document.createElement('div');
29155          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
29156          div.dataset.scopeValue = value;
29157          if (title) div.title = title;
29158          var radio = document.createElement('span');
29159          radio.className = 'scope-option-radio';
29160          var lbl = document.createElement('span');
29161          lbl.textContent = label;
29162          div.appendChild(radio);
29163          div.appendChild(lbl);
29164          div.addEventListener('click', function() {
29165            selectedScope = value;
29166            opts.querySelectorAll('.scope-option').forEach(function(o) {
29167              o.classList.toggle('selected', o.dataset.scopeValue === value);
29168            });
29169          });
29170          return div;
29171        }
29172
29173        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
29174        var sep = document.createElement('span');
29175        sep.className = 'scope-option-sep';
29176        opts.appendChild(sep);
29177        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
29178        subList.forEach(function(s) {
29179          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
29180        });
29181      }
29182
29183      function doCompare() {
29184        if (selected.length < 2) return;
29185        if (selected.length === 2) {
29186          // Two-scan delta (existing flow with scope support).
29187          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
29188          if (selectedScope === 'super') url += '&scope=super';
29189          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29190          window.location.href = url;
29191        } else {
29192          // Multi-scan timeline (N >= 3) — pass scope params too.
29193          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
29194          if (selectedScope === 'super') url += '&scope=super';
29195          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29196          window.location.href = url;
29197        }
29198      }
29199
29200      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
29201      var cbtn = document.getElementById('compare-btn');
29202      if (cbtn) cbtn.addEventListener('click', doCompare);
29203      var pfEl = document.getElementById('project-filter');
29204      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
29205      var bfEl = document.getElementById('branch-filter');
29206      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
29207      var rvBtn = document.getElementById('reset-view-btn');
29208      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
29209      var ppSel = document.getElementById('per-page-sel');
29210      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
29211
29212      var cmpTbody = document.getElementById('compare-tbody');
29213      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
29214        var row = e.target.closest('.compare-row');
29215        if (row) toggleRow(row);
29216      });
29217
29218      (function randomizeWatermarks() {
29219        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29220        if (!wms.length) return;
29221        var placed = [];
29222        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;}
29223        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];}
29224        var half=Math.floor(wms.length/2);
29225        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;});
29226      })();
29227
29228      (function spawnCodeParticles() {
29229        var container = document.getElementById('code-particles');
29230        if (!container) return;
29231        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'];
29232        for (var i = 0; i < 38; i++) {
29233          (function(idx) {
29234            var el = document.createElement('span');
29235            el.className = 'code-particle';
29236            el.textContent = snippets[idx % snippets.length];
29237            var left = Math.random() * 94 + 2;
29238            var top = Math.random() * 88 + 6;
29239            var dur = (Math.random() * 10 + 9).toFixed(1);
29240            var delay = (Math.random() * 18).toFixed(1);
29241            var rot = (Math.random() * 26 - 13).toFixed(1);
29242            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29243            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';
29244            container.appendChild(el);
29245          })(i);
29246        }
29247      })();
29248
29249      // ── Watched folder picker ─────────────────────────────────────────────
29250      (function(){
29251        window.__scanOverlay=function(msg){var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');};
29252        document.addEventListener('submit',function(e){var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){window.__scanOverlay('Updating watched folders');}else if(a.indexOf('/watched-dirs/')!==-1){window.__scanOverlay();}},true);
29253      })();
29254      (function() {
29255        var btn = document.getElementById('add-watched-btn');
29256        if (!btn) return;
29257        btn.addEventListener('click', function() {
29258          fetch('/pick-directory?kind=reports')
29259            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29260            .then(function(data) {
29261              if (!data.cancelled && data.selected_path) {
29262                var form = document.createElement('form');
29263                form.method = 'POST';
29264                form.action = '/watched-dirs/add';
29265                var ri = document.createElement('input');
29266                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29267                var fi = document.createElement('input');
29268                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29269                form.appendChild(ri); form.appendChild(fi);
29270                document.body.appendChild(form);
29271                if (window.__scanOverlay) window.__scanOverlay();
29272                form.submit();
29273              }
29274            })
29275            .catch(function(e) { alert('Could not open folder picker: ' + e); });
29276        });
29277      })();
29278
29279      // ── Submodule chip truncation ─────────────────────────────────────────
29280      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29281        var chips = cell.querySelectorAll('.submod-chip');
29282        var MAX = 4;
29283        if (chips.length <= MAX) return;
29284        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29285        var badge = document.createElement('span');
29286        badge.className = 'submod-overflow-badge';
29287        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29288        badge.textContent = '+' + (chips.length - MAX) + ' more';
29289        cell.appendChild(badge);
29290        cell.style.maxHeight = 'none';
29291      });
29292    })();
29293  </script>
29294  <script nonce="{{ csp_nonce }}">
29295  (function(){
29296    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'}];
29297    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);});}
29298    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29299    function init(){
29300      var btn=document.getElementById('settings-btn');if(!btn)return;
29301      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29302      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>';
29303      document.body.appendChild(m);
29304      var g=document.getElementById('scheme-grid');
29305      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);});
29306      var cl=document.getElementById('settings-close');
29307      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
29308      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');});
29309      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29310      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29311    }
29312    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29313  }());
29314  </script>
29315  <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]';
29316  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;}
29317  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>
29318</body>
29319</html>
29320"##,
29321    ext = "html"
29322)]
29323struct CompareSelectTemplate {
29324    version: &'static str,
29325    entries: Vec<HistoryEntryRow>,
29326    total_scans: usize,
29327    watched_dirs: Vec<String>,
29328    csp_nonce: String,
29329    server_mode: bool,
29330}
29331
29332// ── CompareTemplate ────────────────────────────────────────────────────────────
29333
29334#[derive(Template)]
29335#[template(
29336    source = r##"
29337<!doctype html>
29338<html lang="en">
29339<head>
29340  <meta charset="utf-8">
29341  <meta name="viewport" content="width=device-width, initial-scale=1">
29342  <title>OxideSLOC | Scan Delta</title>
29343  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29344  <style nonce="{{ csp_nonce }}">
29345    :root {
29346      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29347      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29348      --nav:#283790; --nav-2:#013e6b;
29349      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29350      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29351      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29352    }
29353    body.dark-theme {
29354      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29355      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29356    }
29357    *{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;}
29358    .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);}
29359    .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;}
29360    .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));}
29361    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29362    .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;}
29363    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29364    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29365    @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; } }
29366    .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;}
29367    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29368    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29369    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29370    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29371    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
29372    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29373    .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);}
29374    .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;}
29375    .settings-close:hover{color:var(--text);background:var(--surface-2);}
29376    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29377    .settings-modal-body{padding:14px 16px 16px;}
29378    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29379    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29380    .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;}
29381    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29382    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29383    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29384    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29385    .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;}
29386    .tz-select:focus{border-color:var(--oxide);}
29387    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29388    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29389    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29390    .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;}
29391    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29392    .hero-body{display:block;}
29393    .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;}
29394    .btn-back:hover{background:var(--line);}
29395    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29396    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29397    .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;}
29398    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29399    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;}
29400    .muted{color:var(--muted);font-size:14px;}
29401    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29402    .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;}
29403    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29404    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29405    .vpill-arrow{font-size:20px;color:var(--muted);}
29406    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29407    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29408    .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;}
29409    .delta-card.delta-card-wide{padding:22px 24px;}
29410    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29411    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29412    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29413    .delta-card-from{font-size:15px;color:var(--muted);}
29414    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29415    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29416    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29417    .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%;}
29418    .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;}
29419    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29420    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29421    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29422    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29423    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29424    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29425    .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;}
29426    .meta-card-commit:hover{color:var(--oxide);}
29427    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29428    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29429    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29430    .meta-value{color:var(--text);font-size:13px;}
29431    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29432    .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;}
29433    .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);}
29434    .delta-card:hover .dc-tip{display:block;}
29435    .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;}
29436    .export-btn:hover{background:var(--line);}
29437    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29438    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29439    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29440    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29441    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29442    .delta-card-change.zero{color:var(--muted);background:transparent;}
29443    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29444    .delta-card-pct.pos{color:var(--pos);}
29445    .delta-card-pct.neg{color:var(--neg);}
29446    .delta-card-pct.zero{color:var(--muted);}
29447    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29448    .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;}
29449    .insight-card.insight-flag{border-color:var(--oxide);}
29450    .insight-card:hover .dc-tip{display:block;}
29451    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29452    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29453    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29454    .insight-label.flag{color:var(--oxide);}
29455    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29456    .insight-val.pos{color:var(--pos);}
29457    .insight-val.neg{color:var(--neg);}
29458    .insight-val.high{color:#c0392a;}
29459    .insight-val.med{color:#926000;}
29460    .insight-val.low{color:var(--pos);}
29461    body.dark-theme .insight-val.high{color:#ff6b6b;}
29462    body.dark-theme .insight-val.med{color:#f0c060;}
29463    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29464    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29465    .fc-row{display:flex;align-items:center;gap:8px;}
29466    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29467    .fc-label{color:var(--muted);}
29468    .fc-modified .fc-count{color:#926000;}
29469    .fc-added .fc-count{color:var(--pos);}
29470    .fc-removed .fc-count{color:var(--neg);}
29471    .fc-unchanged .fc-count{color:var(--muted);}
29472    .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29473    .fc-total .fc-count{color:var(--text);}
29474    .fc-total .fc-label{font-weight:700;}
29475    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29476    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29477    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29478    .chip.modified{background:#fff2d8;color:#926000;}
29479    .chip.added{background:#e8f5ed;color:#1a8f47;}
29480    .chip.removed{background:#fdeaea;color:#b33b3b;}
29481    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29482    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29483    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29484    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29485    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29486    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29487    .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;}
29488    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29489    .tab-btn:hover:not(.active){background:var(--line);}
29490    .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;}
29491    .btn-reset:hover{background:var(--line);}
29492    .table-wrap{width:100%;overflow-x:auto;}
29493    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29494    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);}
29495    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29496    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29497    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29498    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29499    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29500    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29501    tr:last-child td{border-bottom:none;}
29502    tr:hover td{background:var(--surface-2);}
29503    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29504    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29505    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29506    /* Fixed layout: column widths come from the colgroup, not from scanning every
29507       row. With auto layout a large file matrix forces the browser to re-measure
29508       all cells on each reflow, which freezes the page during sort/resize. */
29509    #delta-table{table-layout:fixed;}
29510    #delta-table col:nth-child(1){width:32%;}
29511    #delta-table col:nth-child(2){width:11%;}
29512    #delta-table col:nth-child(3){width:11%;}
29513    #delta-table col:nth-child(4){width:16%;}
29514    #delta-table col:nth-child(5){width:10%;}
29515    #delta-table col:nth-child(6){width:10%;}
29516    #delta-table col:nth-child(7){width:10%;}
29517    tr.row-added td{background:rgba(26,143,71,0.04);}
29518    tr.row-removed td{background:rgba(179,59,59,0.06);}
29519    tr.row-modified td{background:rgba(146,96,0,0.04);}
29520    tr.row-unchanged td{color:var(--muted);}
29521    tr.row-unchanged .status-badge{opacity:.65;}
29522    .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;}
29523    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29524    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29525    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29526    .status-badge.modified{background:#fff2d8;color:#926000;}
29527    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29528    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29529    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29530    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29531    .delta-val{font-weight:700;}
29532    .delta-val.pos{color:var(--pos);}
29533    .delta-val.neg{color:var(--neg);}
29534    .delta-val.zero{color:var(--muted);}
29535    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29536    .from-to strong{color:var(--text);font-weight:700;}
29537    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29538    .from-to .ft-absent{color:var(--muted);font-weight:600;}
29539    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29540    .site-footer a{color:var(--muted);}
29541    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;}
29542    body.pdf-mode{background:#fff!important;}
29543    body.pdf-mode .page{padding:4px 6px 4px!important;}
29544    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29545    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29546    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29547    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29548    .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;}
29549    .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;}
29550    .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;}
29551    @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));}}
29552    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29553    .path-link:hover{color:var(--oxide-2);}
29554    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29555    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29556    a.vpill-id:hover{color:var(--oxide);}
29557    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29558    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29559    .pagination-info{font-size:13px;color:var(--muted);}
29560    .pagination-btns{display:flex;gap:6px;}
29561    .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;}
29562    .pg-btn:hover:not(:disabled){background:var(--line);}
29563    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29564    .pg-btn:disabled{opacity:.35;cursor:default;}
29565    .per-page-label{font-size:13px;color:var(--muted);}
29566    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;}
29567    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29568    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29569    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29570    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29571    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29572    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29573    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29574    .tab-btn.tab-unchanged{color:var(--muted);}
29575    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29576    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29577    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29578    .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;}
29579    .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;}
29580    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29581    .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;}
29582    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29583    .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;}
29584    .submod-scope-btn:hover{background:var(--line);}
29585    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29586    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29587    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29588    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29589    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29590    body.dark-theme .ic-card{background:var(--surface-2);}
29591    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29592    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29593    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29594    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29595    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29596    .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);}
29597    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29598    .ic-card-h2-row .ic-card-h2{margin:0;}
29599    .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;}
29600    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29601    .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;}
29602    .ic-svg-modal-ov.open{display:flex;}
29603    .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);}
29604    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29605    .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);}
29606    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29607    .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;}
29608    .ic-svg-modal-close:hover{background:var(--line);}
29609    .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;}
29610    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29611    .chart-metric-btn:hover:not(.active){background:var(--line);}
29612    .chart-wrap{width:100%;overflow-x:auto;}
29613    #cmp-tl-svg{display:block;width:100%;}
29614    .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);}
29615    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29616    #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;}
29617  </style>
29618</head>
29619<body>
29620  {{ loading_overlay|safe }}
29621  <div class="background-watermarks" aria-hidden="true">
29622    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29623    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29624    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29625    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29626    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29627    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29628  </div>
29629  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29630  <div class="top-nav">
29631    <div class="top-nav-inner">
29632      <a class="brand" href="/">
29633        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29634        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29635      </a>
29636      <div class="nav-right">
29637        <a class="nav-pill" href="/">Home</a>
29638        <div class="nav-dropdown">
29639          <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>
29640          <div class="nav-dropdown-menu">
29641            <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>
29642          </div>
29643        </div>
29644        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29645        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29646        <div class="nav-dropdown">
29647          <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>
29648          <div class="nav-dropdown-menu">
29649            <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>
29650          </div>
29651        </div>
29652        <div class="server-status-wrap" id="server-status-wrap">
29653          <div class="nav-pill server-online-pill" id="server-status-pill">
29654            <span class="status-dot" id="status-dot"></span>
29655            <span id="server-status-label">Server</span>
29656            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29657          </div>
29658          <div class="server-status-tip">
29659            OxideSLOC is running — accessible on your network.
29660            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29661          </div>
29662        </div>
29663        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29664          <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>
29665        </button>
29666        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29667          <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>
29668          <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>
29669        </button>
29670      </div>
29671    </div>
29672  </div>
29673
29674  <div class="page">
29675    <section class="hero">
29676      <div class="hero-header">
29677        <div>
29678          <h1 class="delta-title">Scan Delta</h1>
29679          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29680          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29681            {% if let Some(sub) = active_submodule %}
29682            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29683            {% else if super_scope_active %}
29684            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29685            {% else %}
29686            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29687            {% endif %}
29688            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29689          </div>
29690        </div>
29691        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29692          <a class="btn-back" href="/compare-scans">
29693            <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>
29694            Compare Scans
29695          </a>
29696          <div class="export-group" style="margin-top:12px;">
29697            <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>
29698            <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>
29699          </div>
29700        </div>
29701      </div>
29702      {% if has_any_submodule_data %}
29703      <div class="submod-scope-bar">
29704        <span class="submod-scope-label">
29705          <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>
29706          Scope:
29707        </span>
29708        <div class="submod-scope-divider"></div>
29709        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29710           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
29711           title="All files — super-repo and all submodules combined">Full scan</a>
29712        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29713           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
29714           title="Only files that are not part of any submodule">Super-repo only</a>
29715        {% for sub in submodule_options %}
29716        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29717           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
29718           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29719        {% endfor %}
29720      </div>
29721      {% endif %}
29722      <div class="hero-body">
29723      <div class="meta-strip">
29724        <div class="delta-card delta-card-meta">
29725          <div class="meta-card-header">
29726            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29727            <div class="meta-card-project-col">
29728              <div class="meta-card-project">{{ project_name }}</div>
29729              {% if has_any_submodule_data %}
29730              {% if let Some(sub) = active_submodule %}
29731              <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>
29732              {% else if super_scope_active %}
29733              <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>
29734              {% else %}
29735              <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>
29736              {% endif %}
29737              {% endif %}
29738            </div>
29739          </div>
29740          {% if !baseline_git_commit.is_empty() %}
29741          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29742          {% else %}
29743          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29744          {% endif %}
29745          <div class="meta-card-rows">
29746            <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>
29747            <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>
29748            <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>
29749            <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>
29750            {% if let Some(tags) = baseline_git_tags %}
29751            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29752            {% endif %}
29753          </div>
29754        </div>
29755        <div class="delta-card delta-card-meta">
29756          <div class="meta-card-header">
29757            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29758            <div class="meta-card-project-col">
29759              <div class="meta-card-project">{{ project_name }}</div>
29760              {% if has_any_submodule_data %}
29761              {% if let Some(sub) = active_submodule %}
29762              <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>
29763              {% else if super_scope_active %}
29764              <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>
29765              {% else %}
29766              <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>
29767              {% endif %}
29768              {% endif %}
29769            </div>
29770          </div>
29771          {% if !current_git_commit.is_empty() %}
29772          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29773          {% else %}
29774          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29775          {% endif %}
29776          <div class="meta-card-rows">
29777            <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>
29778            <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>
29779            <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>
29780            <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>
29781            {% if let Some(tags) = current_git_tags %}
29782            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29783            {% endif %}
29784          </div>
29785        </div>
29786      </div>
29787      <div class="delta-strip">
29788        <div class="delta-card">
29789          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29790          <div class="delta-card-label">Code lines</div>
29791          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29792          <div class="delta-card-to">{{ current_code_fmt }}</div>
29793          {% 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>
29794          {% 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>
29795          {% else %}<div class="delta-card-pct zero">±0%</div>
29796          {% endif %}
29797        </div>
29798        <div class="delta-card">
29799          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29800          <div class="delta-card-label">Files analyzed</div>
29801          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29802          <div class="delta-card-to">{{ current_files_fmt }}</div>
29803          {% 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>
29804          {% 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>
29805          {% else %}<div class="delta-card-pct zero">±0%</div>
29806          {% endif %}
29807        </div>
29808        <div class="delta-card">
29809          <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>
29810          <div class="delta-card-label">Comment lines</div>
29811          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29812          <div class="delta-card-to">{{ current_comments_fmt }}</div>
29813          {% 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>
29814          {% 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>
29815          {% else %}<div class="delta-card-pct zero">±0%</div>
29816          {% endif %}
29817        </div>
29818        {{ coverage_delta_card|safe }}
29819        <div class="delta-card delta-card-wide">
29820          <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>
29821          <div class="delta-card-label">File changes</div>
29822          <div class="file-changes-grid">
29823            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29824            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29825            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29826            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29827            <div class="fc-row fc-total"><span class="fc-count">{{ files_total|commas }}</span><span class="fc-label">Total (modified + added + removed + unchanged)</span></div>
29828          </div>
29829        </div>
29830      </div>
29831      <div class="insights-panel">
29832        <div class="insight-card">
29833          <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>
29834          <div class="insight-label">Lines Added</div>
29835          <div class="insight-val pos">+{{ code_lines_added }}</div>
29836          <div class="insight-sub">New or grown source lines</div>
29837        </div>
29838        <div class="insight-card">
29839          <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>
29840          <div class="insight-label">Lines Removed</div>
29841          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
29842          <div class="insight-sub">Deleted or shrunk source lines</div>
29843        </div>
29844        <div class="insight-card">
29845          <div class="dc-tip up">Total current-scan code lines living in files that changed between the two scans.<br>Counts every code line in a modified file, not just the changed lines.</div>
29846          <div class="insight-label">Lines Modified</div>
29847          <div class="insight-val">{{ code_lines_modified }}</div>
29848          <div class="insight-sub">Code lines in modified files</div>
29849        </div>
29850        <div class="insight-card">
29851          <div class="dc-tip up">Code lines in files that are byte-for-byte identical (same code/comment/blank counts) in both scans.<br>These lines carried over unchanged.</div>
29852          <div class="insight-label">Lines Unmodified</div>
29853          <div class="insight-val">{{ code_lines_unmodified }}</div>
29854          <div class="insight-sub">Code lines in unchanged files</div>
29855        </div>
29856        <div class="insight-card">
29857          <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29858          <div class="insight-label">Lines Total</div>
29859          <div class="insight-val">{{ code_lines_total }}</div>
29860          <div class="insight-sub">Added + removed + modified + unmodified</div>
29861        </div>
29862        <div class="insight-card">
29863          <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>
29864          <div class="insight-label">Churn Rate</div>
29865          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29866          <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>
29867        </div>
29868        {% if scope_flag %}
29869        <div class="insight-card insight-flag">
29870          <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>
29871          <div class="insight-label flag">Scope Signal</div>
29872          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29873          <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>
29874        </div>
29875        {% endif %}
29876      </div>
29877      </div>
29878    </section>
29879
29880    <section class="panel" id="inline-charts-section">
29881      <div class="panel-title">Scan Delta Charts</div>
29882      <div class="ic-grid">
29883        <div class="ic-card" style="grid-column:span 2">
29884          <div class="ic-card-h2-row">
29885            <span class="ic-card-h2">Timeline</span>
29886            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29887              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29888              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29889              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29890              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29891              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29892            </div>
29893            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
29894          </div>
29895          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29896        </div>
29897        <div class="ic-card">
29898          <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>
29899          <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>
29900          <div id="ic-c1"></div>
29901        </div>
29902        <div class="ic-card" id="ic-lang-card">
29903          <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>
29904          <div id="ic-c3"></div>
29905        </div>
29906        <div class="ic-card">
29907          <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>
29908          <div id="ic-c2"></div>
29909        </div>
29910        <div class="ic-card">
29911          <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>
29912          <div id="ic-c4"></div>
29913        </div>
29914      </div>
29915      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29916        <div class="ic-svg-modal">
29917          <div class="ic-svg-modal-hdr">
29918            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29919            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
29920          </div>
29921          <div id="ic-svg-modal-body"></div>
29922        </div>
29923      </div>
29924    </section>
29925
29926    <section class="panel">
29927      <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>
29928      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29929        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29930          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29931          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29932          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29933          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29934          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29935        </div>
29936        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29937          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
29938          <div class="export-group">
29939            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
29940            <button type="button" class="export-btn" id="delta-csv-btn">
29941              <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>
29942              CSV
29943            </button>
29944            <button type="button" class="export-btn" id="delta-xls-btn">
29945              <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>
29946              Excel
29947            </button>
29948          </div>
29949        </div>
29950      </div>
29951
29952      <div class="table-wrap">
29953      <table id="delta-table">
29954        <colgroup>
29955          <col>
29956          <col>
29957          <col>
29958          <col>
29959          <col>
29960          <col>
29961          <col>
29962        </colgroup>
29963        <thead>
29964          <tr id="delta-thead">
29965            <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>
29966            <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>
29967            <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>
29968            <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>
29969            <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>
29970            <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>
29971            <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>
29972          </tr>
29973        </thead>
29974        <tbody id="delta-tbody">
29975          {% for row in file_rows %}
29976          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29977              data-path="{{ row.relative_path }}"
29978              data-language="{{ row.language }}"
29979              data-baseline-code="{{ row.baseline_code }}"
29980              data-current-code="{{ row.current_code }}"
29981              data-code-delta="{{ row.code_delta_str }}"
29982              data-comment-delta="{{ row.comment_delta_str }}"
29983              data-total-delta="{{ row.total_delta_str }}"
29984              data-orig-idx="">
29985            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29986            <td class="hide-sm">{{ row.language }}</td>
29987            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29988            <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>
29989            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29990            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29991            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29992          </tr>
29993          {% endfor %}
29994        </tbody>
29995      </table>
29996      </div>
29997      <div class="pagination">
29998        <span class="pagination-info" id="pg-range-label"></span>
29999        <div class="pagination-btns" id="pg-btns"></div>
30000        <div class="flex-row">
30001          <span class="per-page-label">Show</span>
30002          <select class="per-page" id="per-page-sel">
30003            <option value="10">10 per page</option>
30004            <option value="25" selected>25 per page</option>
30005            <option value="50">50 per page</option>
30006            <option value="100">100 per page</option>
30007          </select>
30008        </div>
30009      </div>
30010    </section>
30011  </div>
30012
30013  <div id="ic-tt"></div>
30014
30015  <footer class="site-footer">
30016    local code analysis - metrics, history and reports
30017    &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>
30018    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30019    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30020    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30021    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
30022  </footer>
30023
30024  <script nonce="{{ csp_nonce }}">
30025    (function () {
30026      var storageKey = 'oxide-sloc-theme';
30027      var body = document.body;
30028      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
30029      var toggle = document.getElementById('theme-toggle');
30030      if (toggle) toggle.addEventListener('click', function () {
30031        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
30032        body.classList.toggle('dark-theme', next === 'dark');
30033        try { localStorage.setItem(storageKey, next); } catch(e) {}
30034      });
30035
30036      (function randomizeWatermarks() {
30037        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30038        if (!wms.length) return;
30039        var placed = [];
30040        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;}
30041        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];}
30042        var half=Math.floor(wms.length/2);
30043        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;});
30044      })();
30045
30046      (function spawnCodeParticles() {
30047        var container = document.getElementById('code-particles');
30048        if (!container) return;
30049        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'];
30050        for (var i = 0; i < 38; i++) {
30051          (function(idx) {
30052            var el = document.createElement('span');
30053            el.className = 'code-particle';
30054            el.textContent = snippets[idx % snippets.length];
30055            var left = Math.random() * 94 + 2;
30056            var top = Math.random() * 88 + 6;
30057            var dur = (Math.random() * 10 + 9).toFixed(1);
30058            var delay = (Math.random() * 18).toFixed(1);
30059            var rot = (Math.random() * 26 - 13).toFixed(1);
30060            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30061            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';
30062            container.appendChild(el);
30063          })(i);
30064        }
30065      })();
30066    })();
30067
30068    var activeStatusFilter = 'all';
30069    var deltaPerPage = 25, deltaCurrPage = 1;
30070
30071    function openFolder(path) {
30072      fetch('/open-path?path=' + encodeURIComponent(path))
30073        .then(function (r) { return r.json(); })
30074        .then(function (d) {
30075          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
30076        })
30077        .catch(function () {});
30078    }
30079
30080    // \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
30081    // The server renders every row once; we lift them into a plain-data array and
30082    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
30083    // filtering run on the array (no DOM churn) and each render rebuilds just one
30084    // page (~25 rows). This keeps every interaction O(page) instead of O(all
30085    // files): a 28k-row table previously re-touched every node on each click
30086    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
30087    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
30088
30089    function parseDeltaNum(str) {
30090      if (!str || str === '\u2014') return 0;
30091      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
30092    }
30093
30094    function captureDelta() {
30095      var tbody = document.getElementById('delta-tbody');
30096      if (!tbody) return;
30097      var rows = tbody.querySelectorAll('.delta-row');
30098      for (var i = 0; i < rows.length; i++) {
30099        var r = rows[i];
30100        DELTA.push({
30101          h: r.innerHTML,
30102          cls: r.className,
30103          path: r.getAttribute('data-path') || '',
30104          lang: r.getAttribute('data-language') || '',
30105          status: r.getAttribute('data-status') || '',
30106          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
30107          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
30108          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
30109          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
30110          td: parseDeltaNum(r.getAttribute('data-total-delta')),
30111          bcs: r.getAttribute('data-baseline-code') || '',
30112          ccs: r.getAttribute('data-current-code') || '',
30113          cds: r.getAttribute('data-code-delta') || '',
30114          cmds: r.getAttribute('data-comment-delta') || '',
30115          tds: r.getAttribute('data-total-delta') || ''
30116        });
30117      }
30118      tbody.innerHTML = '';
30119    }
30120
30121    function applyDeltaQuery() {
30122      var v = (activeStatusFilter === 'all') ? DELTA.slice()
30123        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
30124      if (sortCol) {
30125        var asc = sortOrder === 'asc';
30126        v.sort(function(a, b) {
30127          var va, vb;
30128          if (sortCol === 'path') { va = a.path; vb = b.path; }
30129          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
30130          else if (sortCol === 'status') { va = a.status; vb = b.status; }
30131          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
30132          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
30133          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
30134          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
30135          else { return 0; }
30136          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
30137          return va < vb ? 1 : va > vb ? -1 : 0;
30138        });
30139      }
30140      _deltaView = v;
30141      deltaCurrPage = 1;
30142      renderDeltaPage();
30143    }
30144
30145    function renderDeltaPage() {
30146      var total = _deltaView.length;
30147      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
30148      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
30149      if (deltaCurrPage < 1) deltaCurrPage = 1;
30150      var start = (deltaCurrPage - 1) * deltaPerPage;
30151      var end = Math.min(start + deltaPerPage, total);
30152      var tbody = document.getElementById('delta-tbody');
30153      if (tbody) {
30154        var html = '';
30155        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
30156        tbody.innerHTML = html;
30157      }
30158      var rl = document.getElementById('pg-range-label');
30159      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
30160      var btns = document.getElementById('pg-btns');
30161      if (!btns) return;
30162      btns.innerHTML = '';
30163      if (totalPages <= 1) return;
30164      function makeBtn(lbl, pg, active, disabled) {
30165        var b = document.createElement('button');
30166        b.className = 'pg-btn' + (active ? ' active' : '');
30167        b.textContent = lbl; b.disabled = disabled;
30168        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
30169        return b;
30170      }
30171      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
30172      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
30173      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
30174      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
30175    }
30176
30177    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
30178
30179    function filterRows(status, btn) {
30180      activeStatusFilter = status;
30181      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
30182        b.classList.remove('active');
30183      });
30184      if (btn) btn.classList.add('active');
30185      applyDeltaQuery();
30186    }
30187
30188    // ── Sorting ──────────────────────────────────────────────────────────────
30189    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
30190    sortHeaders.forEach(function(th) {
30191      th.addEventListener('click', function(e) {
30192        if (e.target.classList.contains('col-resize-handle')) return;
30193        var col = th.dataset.sortCol;
30194        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
30195        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30196        th.classList.add('sort-' + sortOrder);
30197        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
30198        applyDeltaQuery();
30199      });
30200    });
30201
30202    // ── Column resize ─────────────────────────────────────────────────────────
30203    (function() {
30204      var table = document.getElementById('delta-table');
30205      if (!table) return;
30206      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
30207      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
30208      ths.forEach(function(th, i) {
30209        var handle = th.querySelector('.col-resize-handle');
30210        if (!handle || !cols[i]) return;
30211        handle.addEventListener('mousedown', function(e) {
30212          e.stopPropagation(); e.preventDefault();
30213          // Lock every column to its current rendered px width and size the table
30214          // to the column total. With table-layout:fixed + width:100% the table is
30215          // pinned to the container, so widening one <col> only rebalances the rest
30216          // and the drag looks inert; pinning px widths lets the column actually
30217          // grow while the wrapper (overflow-x:auto) scrolls.
30218          var startTableW = 0;
30219          for (var k = 0; k < ths.length; k++) {
30220            if (!cols[k]) continue;
30221            var w = ths[k].getBoundingClientRect().width;
30222            cols[k].style.width = w + 'px';
30223            startTableW += w;
30224          }
30225          table.style.width = startTableW + 'px';
30226          var startX = e.clientX;
30227          var startW = ths[i].getBoundingClientRect().width;
30228          handle.classList.add('dragging');
30229          function onMove(ev) {
30230            var newW = Math.max(40, startW + ev.clientX - startX);
30231            cols[i].style.width = newW + 'px';
30232            table.style.width = (startTableW + (newW - startW)) + 'px';
30233          }
30234          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30235          document.addEventListener('mousemove', onMove);
30236          document.addEventListener('mouseup', onUp);
30237        });
30238      });
30239    })();
30240
30241    // ── Reset ─────────────────────────────────────────────────────────────────
30242    window.resetDeltaTable = function() {
30243      sortCol = null; sortOrder = 'asc';
30244      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30245      var table = document.getElementById('delta-table');
30246      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30247      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30248      activeStatusFilter = 'all';
30249      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30250      var allBtn = document.querySelector('.tab-btn');
30251      if (allBtn) allBtn.classList.add('active');
30252      applyDeltaQuery();
30253    };
30254
30255    // Compact number formatter (shared by the delta table; charts define their own locally)
30256    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();}
30257    function fmtFull(n){return Number(n).toLocaleString();}
30258
30259    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30260    function fmtFromTo() {
30261      var tbody = document.getElementById('delta-tbody');
30262      if (!tbody) return;
30263      tbody.querySelectorAll('.delta-row').forEach(function(row) {
30264        var status = row.dataset.status || '';
30265        var ft = row.querySelector('.from-to');
30266        if (!ft) return;
30267        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30268        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30269        var strongs = ft.querySelectorAll('strong');
30270        // Apply fmt() to non-absent strong values
30271        strongs.forEach(function(el) {
30272          var n = parseInt(el.textContent, 10);
30273          if (!isNaN(n)) el.textContent = fmtFull(n);
30274        });
30275        // Safety: force dash for genuinely absent sides
30276        if (status === 'added' && bv === 0) {
30277          var bs = ft.querySelector('strong:first-of-type');
30278          if (bs && bs.textContent === '0') {
30279            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30280          }
30281        }
30282        if (status === 'removed' && cv === 0) {
30283          var cs = ft.querySelector('strong:last-of-type');
30284          if (cs && cs.textContent === '0') {
30285            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30286          }
30287        }
30288      });
30289    }
30290    // Initialize: format the server-rendered rows, lift them into the data model
30291    // (which also clears the DOM), then render only the first page.
30292    fmtFromTo();
30293    captureDelta();
30294    applyDeltaQuery();
30295
30296    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30297    (function() {
30298      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30299        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30300      });
30301      var resetBtn = document.getElementById('delta-reset-btn');
30302      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30303      var csvBtn = document.getElementById('delta-csv-btn');
30304      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30305      var xlsBtn = document.getElementById('delta-xls-btn');
30306      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30307      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30308      function sdFetchUri(path) {
30309        return fetch(path).then(function(r){return r.blob();}).then(function(b){
30310          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30311        }).catch(function(){return '';});
30312      }
30313      function sdInlineImgs(html, cb) {
30314        var paths=[], seen={};
30315        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30316        if(!paths.length){cb(html);return;}
30317        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30318          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30319          .catch(function(){cb(html);});
30320      }
30321      function buildFullPageHtml(pdfMode) {
30322        if(pdfMode) document.body.classList.add('pdf-mode');
30323        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30324        renderDeltaPage();
30325        var html = document.documentElement.outerHTML;
30326        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30327        if(pdfMode) document.body.classList.remove('pdf-mode');
30328        return html;
30329      }
30330      var chartsBtn = document.getElementById('delta-charts-btn');
30331      if (chartsBtn) chartsBtn.addEventListener('click', function() {
30332        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30333        sdInlineImgs(buildFullPageHtml(false), function(html) {
30334          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30335          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30336          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30337          btn.disabled=false;btn.innerHTML=orig;
30338        });
30339      });
30340      var pageHtmlBtn = document.getElementById('page-export-html-btn');
30341      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30342        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30343        sdInlineImgs(buildFullPageHtml(false), function(html) {
30344          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30345          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30346          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30347          btn.disabled=false;btn.innerHTML=orig;
30348        });
30349      });
30350      // PDF export — clean document-style report, not a web page screenshot
30351      function buildDeltaPdfHtml() {
30352        var sd=_sd, dr=getDeltaExportRows();
30353        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30354        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+'%';}
30355        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30356        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30357        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30358        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30359        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30360        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30361        function fmtN(n){return Number(n).toLocaleString();}
30362        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30363        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>';}
30364        var lm={};
30365        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;});
30366        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30367        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30368        // The header/footer flow in normal document order (NOT position:fixed).
30369        // A fixed header repeats on every printed page in Chromium and overlaps
30370        // the content beneath it — silently swallowing the first few table rows of
30371        // pages 2+ and clipping the summary cards on page 1. Letting the header
30372        // flow once at the top and relying on the table's <thead> (which Chromium
30373        // repeats per page) keeps every row visible. `.body` keeps a small inset
30374        // so nothing bleeds to the sheet edge.
30375        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30376          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30377          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30378          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30379          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30380          '.ph-brand em{color:#c45c10;font-style:normal;}'+
30381          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30382          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30383          '.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;}'+
30384          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30385          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30386          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30387          '.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;}'+
30388          '.body{padding:12px 18px 0;}'+
30389          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30390          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30391          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30392          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30393          '.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;}'+
30394          '.meta>div{flex:1 1 0;}'+
30395          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30396          '.sec{margin-bottom:10px;}'+
30397          '.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;}'+
30398          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30399          '.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;}'+
30400          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30401          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30402          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30403          '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;}'+
30404          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30405          'tr:nth-child(even) td{background:#faf8f6;}'+
30406          '.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;}'+
30407          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30408          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30409          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30410          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30411          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30412          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30413          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30414          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30415          '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30416          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30417          '.fcc-n{font-size:18px;font-weight:900;}'+
30418          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30419        var fileRows=dchg.map(function(r){
30420          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30421          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30422            '<td style="'+ss+'">'+esc(st)+'</td>'+
30423            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30424            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30425            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30426        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30427        var more='';
30428        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('');
30429        var extraCards='';
30430        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>';}
30431        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>';}
30432        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30433          '<div class="pdf-header">'+
30434          '<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>'+
30435          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30436          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30437          '</div>'+
30438          '<div class="body">'+
30439          '<div class="sec"><p class="sh">Summary Metrics</p>'+
30440          '<div class="msec">'+
30441          '<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>'+
30442          '<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>'+
30443          '<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>'+
30444          '<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>'+
30445          '<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>'+
30446          '<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>'+
30447          extraCards+'</div></div>'+
30448          '<div class="sec"><p class="sh">File Changes</p>'+
30449          '<div class="fcsec">'+
30450          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30451          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30452          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30453          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30454          '<div class="fcc"><span class="fcc-n" style="color:#1a2035">'+fullN(Number(sd.fm)+Number(sd.fa)+Number(sd.fr)+Number(sd.fu))+'</span><span class="fcc-l">Total (modified + added + removed + unchanged)</span></div>'+
30455          '</div></div>'+
30456          (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>':'')+
30457          '<div class="sec">'+
30458          '<table><thead>'+
30459          '<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>'+
30460          '<tr><th>File</th><th>Language</th><th>Status</th>'+
30461          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30462          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30463          '</div>'+
30464          '<div class="rfoot">'+
30465          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30466          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30467          '</div>'+
30468          '</body></html>';
30469      }
30470      function doDeltaPdf(btn) {
30471        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30472      }
30473      var pdfBtn = document.getElementById('delta-pdf-btn');
30474      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30475      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30476      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30477      if (location.protocol === 'file:') {
30478        [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'; } });
30479        [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'; } });
30480      }
30481      var ppSel = document.getElementById('per-page-sel');
30482      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30483      var pathLink = document.getElementById('project-path-link');
30484      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30485    })();
30486
30487    // ── Export helpers ────────────────────────────────────────────────────────
30488    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
30489    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30490    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);}
30491    function slocMakeXlsx(fname,sd,dr){
30492      var enc=new TextEncoder();
30493      // CRC-32 table
30494      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;}
30495      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;}
30496      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30497      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30498      // Shared string table
30499      var ss=[],si={};
30500      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30501      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30502      // Worksheet builder — each WS() call gets its own row counter R
30503      function WS(){
30504        var R=0,buf=[];
30505        function cl(c){return String.fromCharCode(65+c);}
30506        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30507          '<v>'+S(v)+'</v></c>';}
30508        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30509          (st?' s="'+st+'"':'')+'>'+
30510          '<v>'+(+v)+'</v></c>';}
30511        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30512        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30513          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30514          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30515          '<sheetFormatPr defaultRowHeight="15"/>'+
30516          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30517        return{sc:sc,nc:nc,row:row,xml:xml};
30518      }
30519      // Language breakdown
30520      var lm={};
30521      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;});
30522      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30523      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30524      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30525      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30526      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30527      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30528      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):'';}
30529      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30530      // Summary sheet
30531      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30532      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30533      r1(s1(0,proj,2));
30534      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30535      r1('');
30536      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30537      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))));
30538      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))));
30539      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))));
30540      r1('');
30541      r1(s1(0,'FILE CHANGES',8));
30542      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30543      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30544      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30545      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30546      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30547      r1(s1(0,'Total')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm+sd.fa+sd.fr+sd.fu,4)+s1(4,_tp(sd.fm+sd.fa+sd.fr+sd.fu)));
30548      if(langs.length){
30549        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30550        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30551        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)));});
30552      }
30553      r1('');r1(s1(0,'SCAN METADATA',8));
30554      r1(s1(1,_blabel)+s1(2,_clabel));
30555      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30556      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30557      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"/>');
30558      // File Delta sheet
30559      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30560      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));
30561      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)));});
30562      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30563      // Shared strings XML
30564      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30565        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30566        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30567      // XLSX file map
30568      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30569      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>',
30570        '_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>',
30571        '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>',
30572        '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>',
30573        '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>',
30574        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30575      // ZIP packer — STORED (no compression), compatible with all XLSX readers
30576      var zparts=[],zcds=[],zoff=0,znf=0;
30577      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30578       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30579      ].forEach(function(name){
30580        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30581        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]);
30582        var entry=new Uint8Array(lha.length+nb.length+sz);
30583        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30584        zparts.push(entry);
30585        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));
30586        var cde=new Uint8Array(cda.length+nb.length);
30587        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30588        zcds.push(cde);zoff+=entry.length;znf++;
30589      });
30590      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30591      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]);
30592      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30593      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30594      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30595      zout.set(new Uint8Array(ea),zpos);
30596      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30597      var xurl=URL.createObjectURL(xblob);
30598      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30599      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30600      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30601    }
30602    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;');}
30603    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30604    function getExportFilename(ext){return _exportBase+'.'+ext;}
30605
30606    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 }}'};
30607    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;}
30608    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30609    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30610    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30611    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30612    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):'';}
30613    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30614    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)]];}
30615    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30616    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)];});}
30617    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30618    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30619
30620    // ── Chart HTML report ─────────────────────────────────────────────────────
30621    function slocChartReport(fname, sd, dr) {
30622      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30623      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30624      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30625      function fmt(n){return Number(n).toLocaleString();}
30626      function px(n){return Math.round(n);}
30627      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30628      // Language map
30629      var lm={};
30630      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;});
30631      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30632
30633      // Builds onmouse* attrs for interactive tooltip on each SVG element
30634      function barTT(label,val){
30635        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30636      }
30637
30638      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30639      //    match the Language Code Delta column height) ────────────
30640      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'}];
30641      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30642      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30643      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30644      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30645      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"/>';}
30646      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30647      c1mets.forEach(function(m,i){
30648        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30649        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30650        var gMax=Math.max(m.b,m.c)*1.15||1;
30651        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30652        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>';
30653        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))+'/>';
30654        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>';
30655        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))+'/>';
30656        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>';
30657        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>';
30658        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>';
30659      });
30660      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>';
30661      c1+='</svg>';
30662
30663      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30664      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'}];
30665      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30666      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30667      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30668      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30669      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30670      mets.forEach(function(m,i){
30671        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30672        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30673        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30674        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>';
30675        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30676        if(bw>=52){
30677          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>';
30678        }else{
30679          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30680          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>';
30681        }
30682      });
30683      c2+='</svg>';
30684
30685      // ── Chart 3: Language Code Delta ─────────────────────────────────────
30686      var c3='';
30687      if(langs.length){
30688        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30689        var C3W=550,c3LW=124,c3FW=52;
30690        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30691        var L3rH=30,C3H=langs.length*L3rH+20;
30692        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30693        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30694        langs.forEach(function(l,i){
30695          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30696          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30697          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30698          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>';
30699          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':''))+'/>';
30700          if(bw>=48){
30701            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>';
30702          }else{
30703            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30704            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>';
30705          }
30706          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>';
30707        });
30708        c3+='</svg>';
30709      }
30710
30711      // ── Chart 4: File Change Donut — centered pie with legend below
30712      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;});
30713      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30714      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30715      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">';
30716      var ang=-Math.PI/2;
30717      segs.forEach(function(s){
30718        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30719        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30720        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30721        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30722        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30723        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)+'%')+'/>';
30724        ang+=sw;
30725      });
30726      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>';
30727      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>';
30728      segs.forEach(function(s,i){
30729        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30730        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30731        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>';
30732      });
30733      c4+='</svg>';
30734
30735      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30736      var ttJs='var tt=document.getElementById("ox-tt");'+
30737        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30738        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30739        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30740        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30741        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30742        'function oxHT(){tt.style.display="none";}';
30743
30744      // body max-width keeps charts from inflating beyond design dimensions on
30745      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30746      // each chart's height blows up proportionally, breaking the one-page layout.
30747      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;}'+
30748        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30749        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30750        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30751        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30752        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30753        'svg{display:block;}'+
30754        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30755        '#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;}'+
30756        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30757      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30758        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30759        '<div id="ox-tt"><\/div>'+
30760        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
30761        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
30762        '<div class="two-col">'+
30763        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
30764        '<div class="leg">'+
30765        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30766        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30767        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30768        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
30769        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30770        '<\/div>'+
30771        '<div class="two-col">'+
30772        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30773        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30774        '<\/div>'+
30775        '<script>'+ttJs+'<\/script>'+
30776        '<\/body><\/html>';
30777      slocDownload(html, fname, 'text/html;charset=utf-8;');
30778    }
30779    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30780    window.buildDeltaChartsHtml = function() {
30781      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30782      var sd=_sd;
30783      var projEl=document.querySelector('[data-folder]');
30784      var proj=projEl?projEl.getAttribute('data-folder'):'';
30785      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30786      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30787      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30788      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30789      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";}';
30790      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);}';
30791      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30792        '<div id="ox-tt"><\/div>'+
30793        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30794        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30795        '<div class="two-col">'+
30796        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30797        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30798        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30799        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30800        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30801        '<\/div>'+
30802        '<div class="two-col">'+
30803        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30804        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30805        '<\/div>'+
30806        '<script>'+ttJs+'<\/script>'+
30807        '<\/body><\/html>';
30808    };
30809    // ── Inline delta charts ────────────────────────────────────────────────────
30810    var _icTT=document.getElementById('ic-tt');
30811    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30812    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';};
30813    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30814    window.addEventListener('blur',function(){window.icHT();});
30815    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30816    (function(){
30817      // Theme-aware palette — matches the canonical scheme used by /test-metrics
30818      // charts so every page renders bars/text/grid with the same colours and
30819      // adapts to dark mode (see Design section in CLAUDE.md).
30820      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30821      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30822      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30823      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30824      // washed) so the chart reads with the same weight as /test-metrics.
30825      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30826      var FADE=dark?'#524238':'#e6d0bf';
30827      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30828      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30829      function fmt(n){return Number(n).toLocaleString();}
30830      function px(n){return Math.round(n);}
30831      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30832      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30833      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);});}
30834      var dr=getDeltaExportRows(),sd=_sd,lm={};
30835      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;});
30836      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30837      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30838      // the bars are as tall as the (usually taller) Language Code Delta sibling that
30839      // shares the same grid row, instead of sitting short at the top.
30840      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}];
30841      function drawC1(){
30842        var C1W=600,C1H=188;
30843        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30844        if(host&&card&&host.clientWidth>0){
30845          var avW=host.clientWidth;
30846          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30847          var wantH=availPx*C1W/avW;
30848          if(wantH>C1H)C1H=wantH;
30849        }
30850        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30851        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30852        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"/>';}
30853        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30854        c1mets.forEach(function(m,i){
30855          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30856          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30857          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30858          var gMax=Math.max(m.b,m.c)*1.15||1;
30859          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30860          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>';
30861          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"/>';
30862          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>';
30863          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"/>';
30864          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>';
30865          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>';
30866          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>';
30867        });
30868        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>';
30869        c1+='</svg>';
30870        return c1;
30871      }
30872      var c1=drawC1();
30873      // Chart 2: Delta by Metric
30874      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}];
30875      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30876      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;
30877      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30878      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30879      mets.forEach(function(m,i){
30880        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);
30881        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>';
30882        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30883        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>';}
30884        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>';}
30885      });
30886      c2+='</svg>';
30887      // Chart 3: Language Code Delta
30888      var c3='';
30889      if(langs.length){
30890        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30891        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;
30892        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30893        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30894        langs.forEach(function(l,i){
30895          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);
30896          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>';
30897          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"/>';
30898          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>';}
30899          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>';}
30900          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>';
30901        });
30902        c3+='</svg>';
30903      }
30904      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30905      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30906      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;});
30907      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30908      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30909      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);
30910      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;
30911      if(segs.length===1){
30912        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30913        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+'"/>';
30914      } else {
30915        // Give every visible slice a small minimum sweep, taken from the largest
30916        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30917        // arc whose start and end points coincide, so SVG renders nothing (blank).
30918        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30919        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30920        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30921        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30922        segs.forEach(function(s,si){
30923          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30924          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);
30925          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);
30926          var pct=Math.round(s.v/tot*100);
30927          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"/>';
30928          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>';}
30929          ang+=sw;
30930        });
30931      }
30932      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30933      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30934      segs.forEach(function(s,i){
30935        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30936        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30937        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30938        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30939        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30940        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>';
30941        c4+='</g>';
30942      });
30943      c4+='</svg>';
30944      // Inject the fixed-height siblings first so the grid row settles to the (taller)
30945      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30946      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30947      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);}
30948      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30949      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30950      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30951
30952      // Compare Timeline chart (Baseline vs Current, 2 points)
30953      (function() {
30954        var activeCmpMetric='code';
30955        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30956        function renderCmpTL(metric, targetSvg, targetH) {
30957          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30958          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30959          svg.setAttribute('height',H);
30960          var pad={l:62,r:20,t:32,b:72};
30961          var dark=document.body.classList.contains('dark-theme');
30962          var cmpPts=[
30963            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30964            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30965          ];
30966          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30967          var valid=pts.filter(function(v){return v!=null;});
30968          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;}
30969          var minV=0,maxV=Math.max.apply(null,valid);
30970          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30971          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30972          var cx0=pad.l,cx1=pad.l+plotW;
30973          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30974          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30975          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30976          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30977          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30978          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();}
30979          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30980          var parts=[];
30981          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30982          for(var gi=0;gi<5;gi++){
30983            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30984            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30985            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30986          }
30987          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+'"/>');
30988          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"/>');
30989          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30990                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30991          dotPts.forEach(function(pt){
30992            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>');
30993            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"/>');
30994            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>');
30995            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>');
30996          });
30997          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30998          svg.setAttribute('viewBox','0 0 '+W+' '+H);
30999          svg.innerHTML=parts.join('');
31000          // Hover: crosshair + tooltip (matches multi-scan timeline)
31001          var cmpTT=document.getElementById('ic-tt');
31002          svg.onmousemove=function(e){
31003            var rect=svg.getBoundingClientRect();
31004            var scaleX=W/rect.width;
31005            var mouseX=(e.clientX-rect.left)*scaleX;
31006            var nearest=-1,minDist=Infinity;
31007            var cxArr=[cx0,cx1];
31008            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;}}
31009            if(nearest<0)return;
31010            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
31011            var xhair=svg.querySelector('.cmp-xhair');
31012            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
31013            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"/>';
31014            if(!cmpTT)return;
31015            var clbl=cmpPts[nearest].label;
31016            var scanLbl=nearest===0?'Baseline':'Current';
31017            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>';
31018            var bx=rect.left+(nc/W*rect.width)+18;
31019            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
31020            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
31021          };
31022          svg.onmouseleave=function(){
31023            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
31024            if(cmpTT)cmpTT.style.display='none';
31025          };
31026        }
31027        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
31028          btn.addEventListener('click',function(){
31029            activeCmpMetric=this.dataset.cmpMetric;
31030            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
31031            this.classList.add('active');
31032            renderCmpTL(activeCmpMetric);
31033          });
31034        });
31035        var ttgl=document.getElementById('theme-toggle');
31036        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
31037        if(typeof ResizeObserver!=='undefined'){
31038          var cmpSvg=document.getElementById('cmp-tl-svg');
31039          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
31040        }
31041        // Expose the timeline renderer + current metric so the Full View modal can
31042        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
31043        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
31044        window.__sdGetMetric=function(){return activeCmpMetric;};
31045        renderCmpTL(activeCmpMetric);
31046      })();
31047
31048      // HTML legend hover -> highlight matching SVG bars within the SAME card only
31049      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
31050        var metric=leg.getAttribute('data-highlight');
31051        var parentCard=leg.closest('.ic-card');
31052        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
31053        if(!chartEl)return;
31054        leg.addEventListener('mouseenter',function(){
31055          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
31056            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';}
31057            else{x.style.opacity='0.28';}
31058          });
31059        });
31060        leg.addEventListener('mouseleave',function(){
31061          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
31062        });
31063      });
31064
31065      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
31066      (function(){
31067        var ov=document.getElementById('ic-svg-modal-ov');
31068        var body=document.getElementById('ic-svg-modal-body');
31069        var ttl=document.getElementById('ic-svg-modal-title');
31070        var closeBtn=document.getElementById('ic-svg-modal-close');
31071        if(!ov||!body)return;
31072        function close(){
31073          ov.classList.remove('open');body.innerHTML='';
31074          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
31075          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
31076        }
31077        function open(srcId,title){
31078          var src=document.getElementById(srcId);if(!src)return;
31079          if(ttl)ttl.textContent=title||'';
31080          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
31081          // snapshot stretches and loses interactivity. Re-render it live into the modal at
31082          // full size instead — keeps proportions, animation, crosshair, tooltip and the
31083          // metric tabs working exactly like the inline chart.
31084          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
31085            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
31086            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
31087            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('');
31088            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>';
31089            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
31090            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
31091            ov.classList.add('open');
31092            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
31093            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;}
31094            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
31095              b.addEventListener('click',function(){
31096                if(!window.__sdFvTL)return;
31097                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
31098                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
31099                this.classList.add('active');
31100                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
31101              });
31102            });
31103            return;
31104          }
31105          var card=src.closest('.ic-card');
31106          var legHtml='';
31107          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
31108          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
31109          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;}
31110          body.innerHTML=legHtml+inner;
31111          var svg=body.querySelector('svg');
31112          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
31113          addTT(body);
31114          ov.classList.add('open');
31115        }
31116        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
31117          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
31118        });
31119        if(closeBtn)closeBtn.addEventListener('click',close);
31120        ov.addEventListener('click',function(e){if(e.target===ov)close();});
31121        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
31122      })();
31123
31124      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
31125    })();
31126  </script>
31127  {{ toast_assets|safe }}
31128  <script nonce="{{ csp_nonce }}">
31129  (function(){
31130    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'}];
31131    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);});}
31132    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31133    function init(){
31134      var btn=document.getElementById('settings-btn');if(!btn)return;
31135      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31136      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>';
31137      document.body.appendChild(m);
31138      var g=document.getElementById('scheme-grid');
31139      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);});
31140      var cl=document.getElementById('settings-close');
31141      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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
31142      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');});
31143      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31144      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31145    }
31146    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
31147  }());
31148  </script>
31149  <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]';
31150  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;}
31151  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>
31152</body>
31153</html>
31154"##,
31155    ext = "html"
31156)]
31157// Template structs need many bool fields to pass Askama rendering flags.
31158#[allow(clippy::struct_excessive_bools)]
31159struct CompareTemplate {
31160    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
31161    loading_overlay: String,
31162    version: &'static str,
31163    project_label: String,
31164    baseline_git_commit: String,
31165    current_git_commit: String,
31166    baseline_run_id: String,
31167    current_run_id: String,
31168    baseline_run_id_short: String,
31169    current_run_id_short: String,
31170    baseline_timestamp: String,
31171    baseline_timestamp_utc_ms: i64,
31172    current_timestamp: String,
31173    current_timestamp_utc_ms: i64,
31174    project_path: String,
31175    baseline_code: u64,
31176    current_code: u64,
31177    code_lines_delta_str: String,
31178    code_lines_delta_class: String,
31179    baseline_files: u64,
31180    current_files: u64,
31181    files_analyzed_delta_str: String,
31182    files_analyzed_delta_class: String,
31183    baseline_comments: u64,
31184    current_comments: u64,
31185    comment_lines_delta_str: String,
31186    comment_lines_delta_class: String,
31187    baseline_code_fmt: String,
31188    current_code_fmt: String,
31189    baseline_files_fmt: String,
31190    current_files_fmt: String,
31191    baseline_comments_fmt: String,
31192    current_comments_fmt: String,
31193    code_lines_pct_str: String,
31194    files_analyzed_pct_str: String,
31195    comment_lines_pct_str: String,
31196    code_lines_added: i64,
31197    code_lines_removed: i64,
31198    /// Code lines residing in files modified between the two scans (current-scan counts).
31199    code_lines_modified: i64,
31200    /// Code lines residing in files identical between the two scans.
31201    code_lines_unmodified: i64,
31202    /// Sum of added + removed + modified + unmodified code-line metrics.
31203    code_lines_total: i64,
31204    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
31205    new_scope: bool,
31206    churn_rate_str: String,
31207    churn_rate_class: String,
31208    scope_flag: bool,
31209    files_added: usize,
31210    files_removed: usize,
31211    files_modified: usize,
31212    files_unchanged: usize,
31213    files_total: usize,
31214    file_rows: Vec<CompareFileDeltaRow>,
31215    baseline_git_author: Option<String>,
31216    current_git_author: Option<String>,
31217    baseline_git_branch: String,
31218    current_git_branch: String,
31219    baseline_git_tags: Option<String>,
31220    current_git_tags: Option<String>,
31221    baseline_git_commit_date: Option<String>,
31222    current_git_commit_date: Option<String>,
31223    project_name: String,
31224    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
31225    submodule_options: Vec<String>,
31226    /// True when either run has submodule data — controls whether the scope bar is shown.
31227    has_any_submodule_data: bool,
31228    /// The submodule currently being compared, if the `sub` query param was provided.
31229    active_submodule: Option<String>,
31230    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
31231    super_scope_active: bool,
31232    csp_nonce: String,
31233    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
31234    toast_assets: String,
31235    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
31236    coverage_delta_card: String,
31237    baseline_test_count: u64,
31238    current_test_count: u64,
31239    baseline_coverage_pct: Option<f64>,
31240    current_coverage_pct: Option<f64>,
31241}
31242
31243// ── LoginTemplate ──────────────────────────────────────────────────────────────
31244
31245#[derive(Template)]
31246#[template(
31247    source = r##"
31248<!doctype html>
31249<html lang="en">
31250<head>
31251  <meta charset="utf-8">
31252  <meta name="viewport" content="width=device-width, initial-scale=1">
31253  <title>OxideSLOC | Sign In</title>
31254  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31255  <style nonce="{{ csp_nonce }}">
31256    :root {
31257      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31258      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31259      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31260      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31261    }
31262    *{box-sizing:border-box;}
31263    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);}
31264    .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);}
31265    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31266    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31267    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31268    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31269    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31270    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31271    .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;}
31272    @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));}}
31273    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31274    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31275    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31276    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31277    .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;}
31278    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31279    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;}
31280    input[type=password]:focus{border-color:var(--oxide);}
31281    .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;}
31282    .btn:hover{opacity:.88;}
31283    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31284    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31285  </style>
31286</head>
31287<body>
31288  <div class="background-watermarks" aria-hidden="true">
31289    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31290    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31291    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31292    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31293    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31294    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31295    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31296  </div>
31297  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31298<nav class="top-nav">
31299  <a class="brand" href="/">
31300    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31301    <span class="brand-title">OxideSLOC</span>
31302  </a>
31303</nav>
31304<main class="page">
31305  <div class="card">
31306    <h1>Sign In</h1>
31307    <p class="subtitle">Enter the API key printed when the server started.</p>
31308    {% if has_error %}
31309    <div class="error">Incorrect API key — please try again.</div>
31310    {% endif %}
31311    <form method="POST" action="/auth/login">
31312      <input type="hidden" name="next" value="{{ next_url|e }}">
31313      <label for="key">API Key</label>
31314      <input id="key" type="password" name="key" autocomplete="current-password"
31315             placeholder="Paste your API key here" autofocus>
31316      <button type="submit" class="btn">Sign In</button>
31317    </form>
31318    <p class="hint">
31319      The API key was printed in the terminal when the server started.<br>
31320      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31321      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31322    </p>
31323  </div>
31324</main>
31325<script nonce="{{ csp_nonce }}">
31326(function() {
31327  (function randomizeWatermarks() {
31328    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31329    if (!wms.length) return;
31330    var placed = [];
31331    function tooClose(top, left) {
31332      for (var i = 0; i < placed.length; i++) {
31333        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31334        if (dt < 16 && dl < 12) return true;
31335      }
31336      return false;
31337    }
31338    function pick(leftBand) {
31339      for (var attempt = 0; attempt < 50; attempt++) {
31340        var top = Math.random() * 88 + 2;
31341        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31342        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31343      }
31344      var top = Math.random() * 88 + 2;
31345      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31346      placed.push([top, left]); return [top, left];
31347    }
31348    var half = Math.floor(wms.length / 2);
31349    wms.forEach(function (img, i) {
31350      var pos = pick(i < half);
31351      var size = Math.floor(Math.random() * 100 + 120);
31352      var rot = (Math.random() * 360).toFixed(1);
31353      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31354      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;
31355    });
31356  })();
31357  (function spawnCodeParticles() {
31358    var container = document.getElementById('code-particles');
31359    if (!container) return;
31360    var snippets = [
31361      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31362      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31363      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31364      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31365      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31366    ];
31367    var count = 38;
31368    for (var i = 0; i < count; i++) {
31369      (function(idx) {
31370        var el = document.createElement('span');
31371        el.className = 'code-particle';
31372        el.textContent = snippets[idx % snippets.length];
31373        var left = Math.random() * 94 + 2;
31374        var top = Math.random() * 88 + 6;
31375        var dur = (Math.random() * 10 + 9).toFixed(1);
31376        var delay = (Math.random() * 18).toFixed(1);
31377        var rot = (Math.random() * 26 - 13).toFixed(1);
31378        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31379        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31380        container.appendChild(el);
31381      })(i);
31382    }
31383  })();
31384})();
31385</script>
31386</body>
31387</html>
31388"##,
31389    ext = "html"
31390)]
31391pub(crate) struct LoginTemplate {
31392    pub(crate) csp_nonce: String,
31393    pub(crate) has_error: bool,
31394    pub(crate) next_url: String,
31395    pub(crate) lockout_threshold: u32,
31396}
31397
31398// ── REST API reference page ────────────────────────────────────────────────────
31399
31400#[derive(Template)]
31401#[template(
31402    source = r##"
31403<!doctype html>
31404<html lang="en">
31405<head>
31406  <meta charset="utf-8">
31407  <meta name="viewport" content="width=device-width, initial-scale=1">
31408  <title>OxideSLOC — REST API Reference</title>
31409  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31410  <style nonce="{{ csp_nonce }}">
31411    :root {
31412      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31413      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31414      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31415      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31416      --success:#16a34a;
31417    }
31418    body.dark-theme {
31419      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31420      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31421    }
31422    *{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;}
31423    .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);}
31424    .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;}
31425    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31426    .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));}
31427    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31428    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31429    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31430    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31431    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31432    @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; } }
31433    .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;}
31434    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31435    .nav-pill.active{background:rgba(255,255,255,0.22);}
31436    .nav-dropdown{position:relative;display:inline-flex;}
31437    .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;}
31438    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31439    .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;}
31440    .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;}
31441    .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);}
31442    .nav-dropdown-menu a:last-child{border-bottom:none;}
31443    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31444    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31445    .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;}
31446    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31447    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31448    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);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;}
31449    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31450    .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);}
31451    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31452    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31453    .settings-modal-body{padding:14px 16px 16px;}
31454    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31455    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31456    .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;}
31457    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31458    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31459    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31460    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31461    .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;}
31462    .tz-select:focus{border-color:var(--oxide);}
31463    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31464    .page-header{margin-bottom:28px;}
31465    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31466    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31467    .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;}
31468    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31469    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31470    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31471    .callout strong{font-weight:800;}
31472    .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;}
31473    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31474    .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;}
31475    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31476    .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;}
31477    body.dark-theme .base-url-value{color:var(--accent);}
31478    .section{margin-bottom:36px;}
31479    .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);}
31480    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31481    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31482    .ep-header:hover{background:var(--surface-2);}
31483    .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;}
31484    .method.get{background:#dcfce7;color:#166534;}
31485    .method.post{background:#dbeafe;color:#1e40af;}
31486    .method.delete{background:#fee2e2;color:#991b1b;}
31487    body.dark-theme .method.get{background:#14532d;color:#86efac;}
31488    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31489    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31490    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31491    .ep-path .param{color:var(--oxide-2);}
31492    body.dark-theme .ep-path .param{color:var(--oxide);}
31493    .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;}
31494    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31495    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31496    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31497    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31498    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31499    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31500    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31501    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31502    .ep-card.open .chevron{transform:rotate(180deg);}
31503    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31504    .ep-card.open .ep-body{display:block;}
31505    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31506    .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;}
31507    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31508    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31509    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31510    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31511    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);}
31512    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31513    table.params tr:last-child td{border-bottom:none;}
31514    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31515    .pt-type{color:var(--muted-2);font-size:12px;}
31516    .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;}
31517    .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;}
31518    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31519    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31520    details.schema{margin-bottom:14px;}
31521    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;}
31522    details.schema summary:hover{color:var(--text);}
31523    .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;}
31524    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31525    .curl-wrap{position:relative;}
31526    .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;}
31527    .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;}
31528    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31529    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31530    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31531    .webhook-note a{color:var(--accent-2);text-decoration:none;}
31532    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31533    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31534    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31535    .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;}
31536    @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));}}
31537    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31538    .site-footer a{color:var(--muted);}
31539  </style>
31540</head>
31541<body>
31542  <div class="background-watermarks" aria-hidden="true">
31543    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31544    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31545    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31546    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31547    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31548    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31549    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31550  </div>
31551  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31552  <div class="top-nav">
31553    <div class="top-nav-inner">
31554      <a class="brand" href="/">
31555        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31556        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31557      </a>
31558      <div class="nav-right">
31559        <a class="nav-pill" href="/">Home</a>
31560        <div class="nav-dropdown">
31561          <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>
31562          <div class="nav-dropdown-menu">
31563            <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>
31564          </div>
31565        </div>
31566        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31567        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31568        <div class="nav-dropdown">
31569          <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>
31570          <div class="nav-dropdown-menu">
31571            <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>
31572          </div>
31573        </div>
31574        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31575          <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>
31576        </button>
31577        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31578          <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>
31579          <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>
31580        </button>
31581      </div>
31582    </div>
31583  </div>
31584
31585  <div class="page">
31586    <div class="page-header">
31587      <h1 class="page-title">REST API Reference</h1>
31588      <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>
31589    </div>
31590
31591    {% if has_api_key %}
31592    <div class="callout key-set">
31593      <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>
31594      <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>
31595    </div>
31596    {% else %}
31597    <div class="callout no-key">
31598      <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>
31599      <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>
31600    </div>
31601    {% endif %}
31602
31603    <div class="base-url-bar">
31604      <span class="base-url-label">Base URL</span>
31605      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31606    </div>
31607
31608    <!-- Health -->
31609    <div class="section">
31610      <h2 class="section-title">Health &amp; Status</h2>
31611      <div class="ep-card">
31612        <div class="ep-header">
31613          <span class="method get">GET</span>
31614          <span class="ep-path">/healthz</span>
31615          <span class="auth-badge public">Public</span>
31616          <span class="ep-desc">Server liveness check</span>
31617          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31618        </div>
31619        <div class="ep-body">
31620          <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>
31621          <p class="params-heading">Response</p>
31622          <div class="schema-block">200 OK
31623Content-Type: text/plain
31624
31625ok</div>
31626          <p class="curl-heading">Example</p>
31627          <div class="curl-wrap">
31628            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31629            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31630          </div>
31631        </div>
31632      </div>
31633    </div>
31634
31635    <!-- Badges -->
31636    <div class="section">
31637      <h2 class="section-title">Badges</h2>
31638      <div class="ep-card">
31639        <div class="ep-header">
31640          <span class="method get">GET</span>
31641          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31642          <span class="auth-badge public">Public</span>
31643          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31644          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31645        </div>
31646        <div class="ep-body">
31647          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31648          <p class="params-heading">Path Parameters</p>
31649          <table class="params">
31650            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31651            <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>
31652          </table>
31653          <p class="curl-heading">Example</p>
31654          <div class="curl-wrap">
31655            <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>
31656            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31657          </div>
31658        </div>
31659      </div>
31660    </div>
31661
31662    <!-- Metrics -->
31663    <div class="section">
31664      <h2 class="section-title">Metrics</h2>
31665
31666      <div class="ep-card">
31667        <div class="ep-header">
31668          <span class="method get">GET</span>
31669          <span class="ep-path">/api/metrics/latest</span>
31670          <span class="auth-badge protected">Protected</span>
31671          <span class="ep-desc">Latest scan metrics (JSON)</span>
31672          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31673        </div>
31674        <div class="ep-body">
31675          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31676          <details class="schema"><summary>Response schema</summary>
31677<div class="schema-block">{
31678  "run_id":    string,        // UUID
31679  "timestamp": string,        // ISO-8601 UTC
31680  "project":   string,        // scanned root path
31681  "summary": {
31682    "files_analyzed":       number,
31683    "files_skipped":        number,
31684    "code_lines":           number,
31685    "comment_lines":        number,
31686    "blank_lines":          number,
31687    "total_physical_lines": number,
31688    "functions":            number,
31689    "classes":              number,
31690    "variables":            number,
31691    "imports":              number
31692  },
31693  "languages": [
31694    { "name": string, "files": number, "code_lines": number,
31695      "comment_lines": number, "blank_lines": number,
31696      "functions": number, "classes": number,
31697      "variables": number, "imports": number }
31698  ]
31699}</div></details>
31700          <p class="curl-heading">Example</p>
31701          <div class="curl-wrap">
31702            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31703  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31704            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31705          </div>
31706        </div>
31707      </div>
31708
31709      <div class="ep-card">
31710        <div class="ep-header">
31711          <span class="method get">GET</span>
31712          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31713          <span class="auth-badge protected">Protected</span>
31714          <span class="ep-desc">Metrics for a specific run</span>
31715          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31716        </div>
31717        <div class="ep-body">
31718          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31719          <p class="params-heading">Path Parameters</p>
31720          <table class="params">
31721            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31722            <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>
31723          </table>
31724          <p class="curl-heading">Example</p>
31725          <div class="curl-wrap">
31726            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31727  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
31728            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31729          </div>
31730        </div>
31731      </div>
31732
31733      <div class="ep-card">
31734        <div class="ep-header">
31735          <span class="method get">GET</span>
31736          <span class="ep-path">/api/metrics/history</span>
31737          <span class="auth-badge protected">Protected</span>
31738          <span class="ep-desc">Paginated scan history</span>
31739          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31740        </div>
31741        <div class="ep-body">
31742          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31743          <p class="params-heading">Query Parameters</p>
31744          <table class="params">
31745            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31746            <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>
31747            <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>
31748          </table>
31749          <details class="schema"><summary>Response schema</summary>
31750<div class="schema-block">[{
31751  "run_id":         string,
31752  "timestamp":      string,   // ISO-8601 UTC
31753  "commit":         string | null,
31754  "branch":         string | null,
31755  "tags":           string[],
31756  "code_lines":     number,
31757  "comment_lines":  number,
31758  "blank_lines":    number,
31759  "physical_lines": number,
31760  "files_analyzed": number,
31761  "project_label":  string,
31762  "html_url":       string | null
31763}]</div></details>
31764          <p class="curl-heading">Example</p>
31765          <div class="curl-wrap">
31766            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31767  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31768            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31769          </div>
31770        </div>
31771      </div>
31772
31773      <div class="ep-card">
31774        <div class="ep-header">
31775          <span class="method get">GET</span>
31776          <span class="ep-path">/api/project-history</span>
31777          <span class="auth-badge protected">Protected</span>
31778          <span class="ep-desc">Project-level scan summary</span>
31779          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31780        </div>
31781        <div class="ep-body">
31782          <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>
31783          <p class="params-heading">Query Parameters</p>
31784          <table class="params">
31785            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31786            <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>
31787          </table>
31788          <details class="schema"><summary>Response schema</summary>
31789<div class="schema-block">{
31790  "scan_count":           number,
31791  "last_scan_id":         string | null,
31792  "last_scan_timestamp":  string | null,  // ISO-8601
31793  "last_scan_code_lines": number | null,
31794  "last_git_branch":      string | null,
31795  "last_git_commit":      string | null
31796}</div></details>
31797          <p class="curl-heading">Example</p>
31798          <div class="curl-wrap">
31799            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31800  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31801            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31802          </div>
31803        </div>
31804      </div>
31805
31806      <div class="ep-card">
31807        <div class="ep-header">
31808          <span class="method get">GET</span>
31809          <span class="ep-path">/api/metrics/submodules</span>
31810          <span class="auth-badge protected">Protected</span>
31811          <span class="ep-desc">List known git submodules across scans</span>
31812          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31813        </div>
31814        <div class="ep-body">
31815          <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>
31816          <p class="params-heading">Query Parameters</p>
31817          <table class="params">
31818            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31819            <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>
31820          </table>
31821          <details class="schema"><summary>Response schema</summary>
31822<div class="schema-block">[{
31823  "name":          string,  // submodule name
31824  "relative_path": string   // path relative to the project root
31825}]</div></details>
31826          <p class="curl-heading">Example</p>
31827          <div class="curl-wrap">
31828            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31829  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31830            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31831          </div>
31832        </div>
31833      </div>
31834    </div>
31835
31836    <!-- Async Run Status -->
31837    <div class="section">
31838      <h2 class="section-title">Async Run Status</h2>
31839
31840      <div class="ep-card">
31841        <div class="ep-header">
31842          <span class="method get">GET</span>
31843          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31844          <span class="auth-badge protected">Protected</span>
31845          <span class="ep-desc">Poll scan completion</span>
31846          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31847        </div>
31848        <div class="ep-body">
31849          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31850          <details class="schema"><summary>Response schema</summary>
31851<div class="schema-block">// Running
31852{ "state": "running",  "elapsed_secs": number }
31853
31854// Complete
31855{ "state": "complete", "run_id": string }
31856
31857// Failed
31858{ "state": "failed",   "message": string }</div></details>
31859          <p class="curl-heading">Example</p>
31860          <div class="curl-wrap">
31861            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31862  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
31863            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31864          </div>
31865        </div>
31866      </div>
31867
31868      <div class="ep-card">
31869        <div class="ep-header">
31870          <span class="method get">GET</span>
31871          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31872          <span class="auth-badge protected">Protected</span>
31873          <span class="ep-desc">Poll PDF generation readiness</span>
31874          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31875        </div>
31876        <div class="ep-body">
31877          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31878          <details class="schema"><summary>Response schema</summary>
31879<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31880          <p class="curl-heading">Example</p>
31881          <div class="curl-wrap">
31882            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31883  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
31884            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31885          </div>
31886        </div>
31887      </div>
31888
31889      <div class="ep-card">
31890        <div class="ep-header">
31891          <span class="method post">POST</span>
31892          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31893          <span class="auth-badge protected">Protected</span>
31894          <span class="ep-desc">Cancel a running scan</span>
31895          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31896        </div>
31897        <div class="ep-body">
31898          <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>
31899          <p class="curl-heading">Example</p>
31900          <div class="curl-wrap">
31901            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31902  -H "Authorization: Bearer $SLOC_API_KEY" \
31903  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
31904            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31905          </div>
31906        </div>
31907      </div>
31908    </div>
31909
31910    <!-- Run Management -->
31911    <div class="section">
31912      <h2 class="section-title">Run Management</h2>
31913
31914      <div class="ep-card">
31915        <div class="ep-header">
31916          <span class="method get">GET</span>
31917          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31918          <span class="auth-badge protected">Protected</span>
31919          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31920          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31921        </div>
31922        <div class="ep-body">
31923          <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>
31924          <p class="params-heading">Path Parameters</p>
31925          <table class="params">
31926            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31927            <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>
31928          </table>
31929          <details class="schema"><summary>Response</summary>
31930<div class="schema-block">200 OK — Content-Type: application/zip
31931Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
31932
31933404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
31934          <p class="curl-heading">Example</p>
31935          <div class="curl-wrap">
31936            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31937  -o run.zip \
31938  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
31939            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31940          </div>
31941        </div>
31942      </div>
31943
31944      <div class="ep-card">
31945        <div class="ep-header">
31946          <span class="method delete">DELETE</span>
31947          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31948          <span class="auth-badge protected">Protected</span>
31949          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31950          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31951        </div>
31952        <div class="ep-body">
31953          <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>
31954          <p class="params-heading">Path Parameters</p>
31955          <table class="params">
31956            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31957            <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>
31958          </table>
31959          <details class="schema"><summary>Response</summary>
31960<div class="schema-block">204 No Content — run successfully deleted
31961
31962500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
31963          <p class="curl-heading">Example</p>
31964          <div class="curl-wrap">
31965            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31966  -H "Authorization: Bearer $SLOC_API_KEY" \
31967  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
31968            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31969          </div>
31970        </div>
31971      </div>
31972
31973      <div class="ep-card">
31974        <div class="ep-header">
31975          <span class="method post">POST</span>
31976          <span class="ep-path">/api/runs/cleanup</span>
31977          <span class="auth-badge protected">Protected</span>
31978          <span class="ep-desc">Bulk delete runs older than N days</span>
31979          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31980        </div>
31981        <div class="ep-body">
31982          <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>
31983          <p class="params-heading">Request Body (application/json)</p>
31984          <table class="params">
31985            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31986            <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>
31987          </table>
31988          <details class="schema"><summary>Response schema</summary>
31989<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
31990          <p class="curl-heading">Example — delete runs older than 60 days</p>
31991          <div class="curl-wrap">
31992            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31993  -H "Authorization: Bearer $SLOC_API_KEY" \
31994  -H "Content-Type: application/json" \
31995  -d '{"older_than_days":60}' \
31996  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31997            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31998          </div>
31999        </div>
32000      </div>
32001    </div>
32002
32003    <!-- Retention Policy -->
32004    <div class="section">
32005      <h2 class="section-title">Retention Policy</h2>
32006
32007      <div class="ep-card">
32008        <div class="ep-header">
32009          <span class="method get">GET</span>
32010          <span class="ep-path">/api/cleanup-policy</span>
32011          <span class="auth-badge protected">Protected</span>
32012          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
32013          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32014        </div>
32015        <div class="ep-body">
32016          <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>
32017          <details class="schema"><summary>Response schema</summary>
32018<div class="schema-block">{
32019  "policy": {
32020    "enabled":       boolean,
32021    "max_age_days":  number | null,   // delete runs older than N days
32022    "max_run_count": number | null,   // keep only the N most recent runs
32023    "interval_hours": number          // hours between background passes
32024  } | null,
32025  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
32026  "last_run_deleted": number | null   // runs deleted in last pass
32027}</div></details>
32028          <p class="curl-heading">Example</p>
32029          <div class="curl-wrap">
32030            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32031  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32032            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
32033          </div>
32034        </div>
32035      </div>
32036
32037      <div class="ep-card">
32038        <div class="ep-header">
32039          <span class="method post">POST</span>
32040          <span class="ep-path">/api/cleanup-policy</span>
32041          <span class="auth-badge protected">Protected</span>
32042          <span class="ep-desc">Save or update the retention policy</span>
32043          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32044        </div>
32045        <div class="ep-body">
32046          <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>
32047          <p class="params-heading">Request Body (application/json)</p>
32048          <table class="params">
32049            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32050            <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>
32051            <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>
32052            <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>
32053            <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>
32054          </table>
32055          <details class="schema"><summary>Response</summary>
32056<div class="schema-block">204 No Content — policy saved and task (re)started
32057
32058500 Internal Server Error — { "error": string }</div></details>
32059          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
32060          <div class="curl-wrap">
32061            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
32062  -H "Authorization: Bearer $SLOC_API_KEY" \
32063  -H "Content-Type: application/json" \
32064  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
32065  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32066            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
32067          </div>
32068        </div>
32069      </div>
32070
32071      <div class="ep-card">
32072        <div class="ep-header">
32073          <span class="method post">POST</span>
32074          <span class="ep-path">/api/cleanup-policy/run-now</span>
32075          <span class="auth-badge protected">Protected</span>
32076          <span class="ep-desc">Trigger an immediate cleanup pass</span>
32077          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32078        </div>
32079        <div class="ep-body">
32080          <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>
32081          <details class="schema"><summary>Response schema</summary>
32082<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
32083          <p class="curl-heading">Example</p>
32084          <div class="curl-wrap">
32085            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
32086  -H "Authorization: Bearer $SLOC_API_KEY" \
32087  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
32088            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
32089          </div>
32090        </div>
32091      </div>
32092
32093      <div class="ep-card">
32094        <div class="ep-header">
32095          <span class="method delete">DELETE</span>
32096          <span class="ep-path">/api/cleanup-policy</span>
32097          <span class="auth-badge protected">Protected</span>
32098          <span class="ep-desc">Remove the retention policy and stop the background task</span>
32099          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32100        </div>
32101        <div class="ep-body">
32102          <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>
32103          <details class="schema"><summary>Response</summary>
32104<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
32105          <p class="curl-heading">Example</p>
32106          <div class="curl-wrap">
32107            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
32108  -H "Authorization: Bearer $SLOC_API_KEY" \
32109  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32110            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
32111          </div>
32112        </div>
32113      </div>
32114    </div>
32115
32116    <!-- Scan Profiles -->
32117    <div class="section">
32118      <h2 class="section-title">Scan Profiles</h2>
32119
32120      <div class="ep-card">
32121        <div class="ep-header">
32122          <span class="method get">GET</span>
32123          <span class="ep-path">/api/scan-profiles</span>
32124          <span class="auth-badge protected">Protected</span>
32125          <span class="ep-desc">List saved scan profiles</span>
32126          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32127        </div>
32128        <div class="ep-body">
32129          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
32130          <details class="schema"><summary>Response schema</summary>
32131<div class="schema-block">{
32132  "profiles": [{
32133    "id":         string,   // UUID
32134    "name":       string,
32135    "created_at": string,   // ISO-8601
32136    "params":     object
32137  }]
32138}</div></details>
32139          <p class="curl-heading">Example</p>
32140          <div class="curl-wrap">
32141            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32142  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32143            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
32144          </div>
32145        </div>
32146      </div>
32147
32148      <div class="ep-card">
32149        <div class="ep-header">
32150          <span class="method post">POST</span>
32151          <span class="ep-path">/api/scan-profiles</span>
32152          <span class="auth-badge protected">Protected</span>
32153          <span class="ep-desc">Save a scan profile</span>
32154          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32155        </div>
32156        <div class="ep-body">
32157          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
32158          <p class="params-heading">Request Body (application/json)</p>
32159          <table class="params">
32160            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32161            <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>
32162            <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>
32163          </table>
32164          <details class="schema"><summary>Response schema</summary>
32165<div class="schema-block">{ "ok": true }</div></details>
32166          <p class="curl-heading">Example</p>
32167          <div class="curl-wrap">
32168            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
32169  -H "Authorization: Bearer $SLOC_API_KEY" \
32170  -H "Content-Type: application/json" \
32171  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
32172  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32173            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
32174          </div>
32175        </div>
32176      </div>
32177
32178      <div class="ep-card">
32179        <div class="ep-header">
32180          <span class="method delete">DELETE</span>
32181          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
32182          <span class="auth-badge protected">Protected</span>
32183          <span class="ep-desc">Delete a scan profile</span>
32184          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32185        </div>
32186        <div class="ep-body">
32187          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
32188          <p class="params-heading">Path Parameters</p>
32189          <table class="params">
32190            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32191            <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>
32192          </table>
32193          <details class="schema"><summary>Response schema</summary>
32194<div class="schema-block">{ "ok": true }</div></details>
32195          <p class="curl-heading">Example</p>
32196          <div class="curl-wrap">
32197            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
32198  -H "Authorization: Bearer $SLOC_API_KEY" \
32199  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
32200            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
32201          </div>
32202        </div>
32203      </div>
32204    </div>
32205
32206    <!-- Scheduled Scans -->
32207    <div class="section">
32208      <h2 class="section-title">Scheduled Scans</h2>
32209
32210      <div class="ep-card">
32211        <div class="ep-header">
32212          <span class="method get">GET</span>
32213          <span class="ep-path">/api/schedules</span>
32214          <span class="auth-badge protected">Protected</span>
32215          <span class="ep-desc">List configured schedules</span>
32216          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32217        </div>
32218        <div class="ep-body">
32219          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
32220          <p class="curl-heading">Example</p>
32221          <div class="curl-wrap">
32222            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32223  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32224            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
32225          </div>
32226        </div>
32227      </div>
32228
32229      <div class="ep-card">
32230        <div class="ep-header">
32231          <span class="method post">POST</span>
32232          <span class="ep-path">/api/schedules</span>
32233          <span class="auth-badge protected">Protected</span>
32234          <span class="ep-desc">Create a schedule</span>
32235          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32236        </div>
32237        <div class="ep-body">
32238          <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>
32239          <p class="curl-heading">Example</p>
32240          <div class="curl-wrap">
32241            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32242  -H "Authorization: Bearer $SLOC_API_KEY" \
32243  -H "Content-Type: application/json" \
32244  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32245  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32246            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32247          </div>
32248        </div>
32249      </div>
32250
32251      <div class="ep-card">
32252        <div class="ep-header">
32253          <span class="method delete">DELETE</span>
32254          <span class="ep-path">/api/schedules</span>
32255          <span class="auth-badge protected">Protected</span>
32256          <span class="ep-desc">Delete a schedule</span>
32257          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32258        </div>
32259        <div class="ep-body">
32260          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32261          <p class="curl-heading">Example</p>
32262          <div class="curl-wrap">
32263            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32264  -H "Authorization: Bearer $SLOC_API_KEY" \
32265  -H "Content-Type: application/json" \
32266  -d '{"id":"&lt;schedule_id&gt;"}' \
32267  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32268            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32269          </div>
32270        </div>
32271      </div>
32272    </div>
32273
32274    <!-- Git Browser -->
32275    <div class="section">
32276      <h2 class="section-title">Git Browser</h2>
32277
32278      <div class="ep-card">
32279        <div class="ep-header">
32280          <span class="method get">GET</span>
32281          <span class="ep-path">/api/git/refs</span>
32282          <span class="auth-badge protected">Protected</span>
32283          <span class="ep-desc">List git refs for a repository</span>
32284          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32285        </div>
32286        <div class="ep-body">
32287          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32288          <p class="params-heading">Query Parameters</p>
32289          <table class="params">
32290            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32291            <tr><td class="pt-name">repo</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>
32292          </table>
32293          <p class="curl-heading">Example</p>
32294          <div class="curl-wrap">
32295            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32296  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
32297            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32298          </div>
32299        </div>
32300      </div>
32301
32302      <div class="ep-card">
32303        <div class="ep-header">
32304          <span class="method get">GET</span>
32305          <span class="ep-path">/api/git/scan-ref</span>
32306          <span class="auth-badge protected">Protected</span>
32307          <span class="ep-desc">SLOC-scan a specific git ref</span>
32308          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32309        </div>
32310        <div class="ep-body">
32311          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32312          <p class="params-heading">Query Parameters</p>
32313          <table class="params">
32314            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32315            <tr><td class="pt-name">repo</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>
32316            <tr><td class="pt-name">ref_name</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
32317          </table>
32318          <p class="curl-heading">Example</p>
32319          <div class="curl-wrap">
32320            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32321  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?repo=/path/to/repo&amp;ref_name=main"</pre>
32322            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32323          </div>
32324        </div>
32325      </div>
32326
32327      <div class="ep-card">
32328        <div class="ep-header">
32329          <span class="method get">GET</span>
32330          <span class="ep-path">/api/git/compare-refs</span>
32331          <span class="auth-badge protected">Protected</span>
32332          <span class="ep-desc">Compare SLOC across two git refs</span>
32333          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32334        </div>
32335        <div class="ep-body">
32336          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32337          <p class="params-heading">Query Parameters</p>
32338          <table class="params">
32339            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32340            <tr><td class="pt-name">repo</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>
32341            <tr><td class="pt-name">baseline_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
32342            <tr><td class="pt-name">current_ref</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>
32343          </table>
32344          <p class="curl-heading">Example</p>
32345          <div class="curl-wrap">
32346            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32347  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?repo=/path/to/repo&amp;baseline_ref=v1.0&amp;current_ref=main"</pre>
32348            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32349          </div>
32350        </div>
32351      </div>
32352    </div>
32353
32354    <!-- Webhooks -->
32355    <div class="section">
32356      <h2 class="section-title">Webhooks</h2>
32357      <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>
32358
32359      <div class="ep-card">
32360        <div class="ep-header">
32361          <span class="method post">POST</span>
32362          <span class="ep-path">/webhooks/github</span>
32363          <span class="auth-badge hmac">HMAC</span>
32364          <span class="ep-desc">GitHub push event receiver</span>
32365          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32366        </div>
32367        <div class="ep-body">
32368          <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>
32369          <p class="params-heading">Required Headers</p>
32370          <table class="params">
32371            <tr><th>Header</th><th>Value</th></tr>
32372            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32373            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32374            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32375          </table>
32376        </div>
32377      </div>
32378
32379      <div class="ep-card">
32380        <div class="ep-header">
32381          <span class="method post">POST</span>
32382          <span class="ep-path">/webhooks/gitlab</span>
32383          <span class="auth-badge hmac">HMAC</span>
32384          <span class="ep-desc">GitLab push event receiver</span>
32385          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32386        </div>
32387        <div class="ep-body">
32388          <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>
32389          <p class="params-heading">Required Headers</p>
32390          <table class="params">
32391            <tr><th>Header</th><th>Value</th></tr>
32392            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32393            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32394            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32395          </table>
32396        </div>
32397      </div>
32398
32399      <div class="ep-card">
32400        <div class="ep-header">
32401          <span class="method post">POST</span>
32402          <span class="ep-path">/webhooks/bitbucket</span>
32403          <span class="auth-badge hmac">HMAC</span>
32404          <span class="ep-desc">Bitbucket push event receiver</span>
32405          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32406        </div>
32407        <div class="ep-body">
32408          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32409          <p class="params-heading">Required Headers</p>
32410          <table class="params">
32411            <tr><th>Header</th><th>Value</th></tr>
32412            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32413            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32414          </table>
32415        </div>
32416      </div>
32417    </div>
32418
32419    <!-- Config -->
32420    <div class="section">
32421      <h2 class="section-title">Config Import / Export</h2>
32422
32423      <div class="ep-card">
32424        <div class="ep-header">
32425          <span class="method get">GET</span>
32426          <span class="ep-path">/export-config</span>
32427          <span class="auth-badge protected">Protected</span>
32428          <span class="ep-desc">Export server configuration as JSON</span>
32429          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32430        </div>
32431        <div class="ep-body">
32432          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32433          <p class="curl-heading">Example</p>
32434          <div class="curl-wrap">
32435            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32436  -o config.json \
32437  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32438            <button class="curl-copy-btn" data-target="c-export">Copy</button>
32439          </div>
32440        </div>
32441      </div>
32442
32443      <div class="ep-card">
32444        <div class="ep-header">
32445          <span class="method post">POST</span>
32446          <span class="ep-path">/import-config</span>
32447          <span class="auth-badge protected">Protected</span>
32448          <span class="ep-desc">Import server configuration</span>
32449          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32450        </div>
32451        <div class="ep-body">
32452          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32453          <p class="curl-heading">Example</p>
32454          <div class="curl-wrap">
32455            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32456  -H "Authorization: Bearer $SLOC_API_KEY" \
32457  -H "Content-Type: application/json" \
32458  -d @config.json \
32459  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32460            <button class="curl-copy-btn" data-target="c-import">Copy</button>
32461          </div>
32462        </div>
32463      </div>
32464    </div>
32465
32466    <!-- CI Ingest -->
32467    <div class="section">
32468      <h2 class="section-title">CI Ingest</h2>
32469
32470      <div class="ep-card">
32471        <div class="ep-header">
32472          <span class="method post">POST</span>
32473          <span class="ep-path">/api/ingest</span>
32474          <span class="auth-badge protected">Protected</span>
32475          <span class="ep-desc">Push a pre-computed scan result from CI</span>
32476          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32477        </div>
32478        <div class="ep-body">
32479          <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>
32480          <p class="params-heading">Query Parameters</p>
32481          <table class="params">
32482            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32483            <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>
32484          </table>
32485          <p class="params-heading">Request Body (application/json)</p>
32486          <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>
32487          <details class="schema"><summary>Response schema</summary>
32488<div class="schema-block">// 201 Created
32489{
32490  "run_id":   string,  // UUID of the ingested run
32491  "view_url": string   // relative URL to the report page
32492}</div></details>
32493          <p class="curl-heading">Example</p>
32494          <div class="curl-wrap">
32495            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32496  -H "Authorization: Bearer $SLOC_API_KEY" \
32497  -H "Content-Type: application/json" \
32498  -d @result.json \
32499  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32500            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32501          </div>
32502        </div>
32503      </div>
32504    </div>
32505
32506    <!-- Artifact Download -->
32507    <div class="section">
32508      <h2 class="section-title">Artifact Download</h2>
32509
32510      <div class="ep-card">
32511        <div class="ep-header">
32512          <span class="method get">GET</span>
32513          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32514          <span class="auth-badge protected">Protected</span>
32515          <span class="ep-desc">Download or view a scan artifact</span>
32516          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32517        </div>
32518        <div class="ep-body">
32519          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32520          <p class="params-heading">Path Parameters</p>
32521          <table class="params">
32522            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32523            <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>
32524            <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>
32525          </table>
32526          <p class="params-heading">Query Parameters</p>
32527          <table class="params">
32528            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32529            <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>
32530          </table>
32531          <p class="curl-heading">Example — download JSON result</p>
32532          <div class="curl-wrap">
32533            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32534  -o result.json \
32535  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
32536            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32537          </div>
32538        </div>
32539      </div>
32540    </div>
32541
32542    <!-- Embed Widget -->
32543    <div class="section">
32544      <h2 class="section-title">Embed Widget</h2>
32545
32546      <div class="ep-card">
32547        <div class="ep-header">
32548          <span class="method get">GET</span>
32549          <span class="ep-path">/embed/summary</span>
32550          <span class="auth-badge protected">Protected</span>
32551          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32552          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32553        </div>
32554        <div class="ep-body">
32555          <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>
32556          <p class="params-heading">Query Parameters</p>
32557          <table class="params">
32558            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32559            <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>
32560            <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>
32561          </table>
32562          <p class="curl-heading">Example</p>
32563          <div class="curl-wrap">
32564            <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"
32565        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
32566            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32567          </div>
32568        </div>
32569      </div>
32570    </div>
32571
32572    <!-- Confluence Integration -->
32573    <div class="section">
32574      <h2 class="section-title">Confluence Integration</h2>
32575
32576      <div class="ep-card">
32577        <div class="ep-header">
32578          <span class="method get">GET</span>
32579          <span class="ep-path">/api/confluence/config</span>
32580          <span class="auth-badge protected">Protected</span>
32581          <span class="ep-desc">Get current Confluence configuration</span>
32582          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32583        </div>
32584        <div class="ep-body">
32585          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32586          <details class="schema"><summary>Response schema</summary>
32587<div class="schema-block">{
32588  "configured":     boolean,
32589  "tier":           "cloud" | "server",
32590  "base_url":       string,
32591  "username":       string,
32592  "api_token_set":  boolean,
32593  "space_key":      string,
32594  "parent_page_id": string | null,
32595  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
32596}</div></details>
32597          <p class="curl-heading">Example</p>
32598          <div class="curl-wrap">
32599            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32600  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32601            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32602          </div>
32603        </div>
32604      </div>
32605
32606      <div class="ep-card">
32607        <div class="ep-header">
32608          <span class="method post">POST</span>
32609          <span class="ep-path">/api/confluence/config</span>
32610          <span class="auth-badge protected">Protected</span>
32611          <span class="ep-desc">Save Confluence configuration</span>
32612          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32613        </div>
32614        <div class="ep-body">
32615          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32616          <p class="params-heading">Request Body (application/json)</p>
32617          <table class="params">
32618            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32619            <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>
32620            <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>
32621            <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>
32622            <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>
32623            <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>
32624            <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>
32625            <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>
32626          </table>
32627          <details class="schema"><summary>Response schema</summary>
32628<div class="schema-block">{ "ok": true }</div></details>
32629          <p class="curl-heading">Example</p>
32630          <div class="curl-wrap">
32631            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32632  -H "Authorization: Bearer $SLOC_API_KEY" \
32633  -H "Content-Type: application/json" \
32634  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32635  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32636            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32637          </div>
32638        </div>
32639      </div>
32640
32641      <div class="ep-card">
32642        <div class="ep-header">
32643          <span class="method post">POST</span>
32644          <span class="ep-path">/api/confluence/test</span>
32645          <span class="auth-badge protected">Protected</span>
32646          <span class="ep-desc">Test Confluence connection</span>
32647          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32648        </div>
32649        <div class="ep-body">
32650          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32651          <details class="schema"><summary>Response schema</summary>
32652<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32653          <p class="curl-heading">Example</p>
32654          <div class="curl-wrap">
32655            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32656  -H "Authorization: Bearer $SLOC_API_KEY" \
32657  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32658            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32659          </div>
32660        </div>
32661      </div>
32662
32663      <div class="ep-card">
32664        <div class="ep-header">
32665          <span class="method post">POST</span>
32666          <span class="ep-path">/api/confluence/post</span>
32667          <span class="auth-badge protected">Protected</span>
32668          <span class="ep-desc">Publish a scan report to Confluence</span>
32669          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32670        </div>
32671        <div class="ep-body">
32672          <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>
32673          <p class="params-heading">Request Body (application/json)</p>
32674          <table class="params">
32675            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32676            <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>
32677            <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>
32678            <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>
32679          </table>
32680          <details class="schema"><summary>Response schema</summary>
32681<div class="schema-block">// 200 OK
32682{ "ok": true, "page_id": string }
32683
32684// 400 / 502 on error
32685{ "ok": false, "error": string }</div></details>
32686          <p class="curl-heading">Example</p>
32687          <div class="curl-wrap">
32688            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32689  -H "Authorization: Bearer $SLOC_API_KEY" \
32690  -H "Content-Type: application/json" \
32691  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
32692  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32693            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32694          </div>
32695        </div>
32696      </div>
32697
32698      <div class="ep-card">
32699        <div class="ep-header">
32700          <span class="method get">GET</span>
32701          <span class="ep-path">/api/confluence/wiki-markup</span>
32702          <span class="auth-badge protected">Protected</span>
32703          <span class="ep-desc">Get Confluence wiki markup for a run</span>
32704          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32705        </div>
32706        <div class="ep-body">
32707          <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>
32708          <p class="params-heading">Query Parameters</p>
32709          <table class="params">
32710            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32711            <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>
32712          </table>
32713          <p class="curl-heading">Example</p>
32714          <div class="curl-wrap">
32715            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32716  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
32717            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32718          </div>
32719        </div>
32720      </div>
32721    </div>
32722
32723    <!-- Authentication -->
32724    <div class="section">
32725      <h2 class="section-title">Authentication</h2>
32726      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32727
32728      <div class="ep-card">
32729        <div class="ep-header">
32730          <span class="method get">GET</span>
32731          <span class="ep-path">/auth/login</span>
32732          <span class="auth-badge public">Public</span>
32733          <span class="ep-desc">Login page</span>
32734          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32735        </div>
32736        <div class="ep-body">
32737          <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>
32738          <p class="params-heading">Query Parameters</p>
32739          <table class="params">
32740            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32741            <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>
32742            <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>
32743          </table>
32744        </div>
32745      </div>
32746
32747      <div class="ep-card">
32748        <div class="ep-header">
32749          <span class="method post">POST</span>
32750          <span class="ep-path">/auth/login</span>
32751          <span class="auth-badge public">Public</span>
32752          <span class="ep-desc">Submit credentials and get a session cookie</span>
32753          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32754        </div>
32755        <div class="ep-body">
32756          <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>
32757          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32758          <table class="params">
32759            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32760            <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>
32761            <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>
32762          </table>
32763          <p class="curl-heading">Example</p>
32764          <div class="curl-wrap">
32765            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32766  -d "key=$SLOC_API_KEY&amp;next=/" \
32767  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32768            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32769          </div>
32770        </div>
32771      </div>
32772    </div>
32773
32774    <!-- Coverage Suggestion -->
32775    <div class="section">
32776      <h2 class="section-title">Coverage Suggestion</h2>
32777
32778      <div class="ep-card">
32779        <div class="ep-header">
32780          <span class="method get">GET</span>
32781          <span class="ep-path">/api/suggest-coverage</span>
32782          <span class="auth-badge protected">Protected</span>
32783          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32784          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32785        </div>
32786        <div class="ep-body">
32787          <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>
32788          <p class="params-heading">Query Parameters</p>
32789          <table class="params">
32790            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32791            <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>
32792          </table>
32793          <details class="schema"><summary>Response schema</summary>
32794<div class="schema-block">{
32795  "found": string | null,  // absolute path to the coverage file, if detected
32796  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32797  "hint":  string | null   // shell command to generate coverage if not found
32798}</div></details>
32799          <p class="curl-heading">Example</p>
32800          <div class="curl-wrap">
32801            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32802  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32803            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32804          </div>
32805        </div>
32806      </div>
32807    </div>
32808
32809  </div>
32810
32811  <footer class="site-footer">
32812    local code analysis - metrics, history and reports
32813    &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>
32814    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32815    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32816    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32817    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
32818  </footer>
32819
32820  <script nonce="{{ csp_nonce }}">
32821    (function () {
32822      var base = window.location.origin;
32823      document.getElementById('base-url').textContent = base;
32824      document.querySelectorAll('.base-url-slot').forEach(function (el) {
32825        el.textContent = base;
32826      });
32827
32828      document.querySelectorAll('.ep-header').forEach(function (hdr) {
32829        hdr.addEventListener('click', function () {
32830          hdr.closest('.ep-card').classList.toggle('open');
32831        });
32832      });
32833
32834      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32835        btn.addEventListener('click', function () {
32836          var targetId = btn.dataset.target;
32837          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32838          if (!pre) return;
32839          navigator.clipboard.writeText(pre.textContent).then(function () {
32840            btn.textContent = 'Copied!';
32841            btn.classList.add('copied');
32842            setTimeout(function () {
32843              btn.textContent = 'Copy';
32844              btn.classList.remove('copied');
32845            }, 2000);
32846          });
32847        });
32848      });
32849
32850      var storageKey = 'oxide-sloc-theme';
32851      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32852      var themeBtn = document.getElementById('theme-toggle');
32853      if (themeBtn) {
32854        themeBtn.addEventListener('click', function () {
32855          var dark = document.body.classList.toggle('dark-theme');
32856          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32857        });
32858      }
32859      (function() {
32860        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'}];
32861        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);});}
32862        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32863        var btn=document.getElementById('settings-btn');if(!btn)return;
32864        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32865        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>';
32866        document.body.appendChild(m);
32867        var g=document.getElementById('scheme-grid');
32868        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);});
32869        var cl=document.getElementById('settings-close');
32870        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.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();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:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};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);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);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);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
32871        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');});
32872        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32873        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32874      })();
32875      (function randomizeWatermarks() {
32876        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32877        if (!wms.length) return;
32878        var placed = [];
32879        function tooClose(top, left) {
32880          for (var i = 0; i < placed.length; i++) {
32881            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32882            if (dt < 16 && dl < 12) return true;
32883          }
32884          return false;
32885        }
32886        function pick(leftBand) {
32887          for (var attempt = 0; attempt < 50; attempt++) {
32888            var top = Math.random() * 88 + 2;
32889            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32890            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32891          }
32892          var top = Math.random() * 88 + 2;
32893          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32894          placed.push([top, left]); return [top, left];
32895        }
32896        var half = Math.floor(wms.length / 2);
32897        wms.forEach(function (img, i) {
32898          var pos = pick(i < half);
32899          var size = Math.floor(Math.random() * 100 + 120);
32900          var rot = (Math.random() * 360).toFixed(1);
32901          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32902          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;
32903        });
32904      })();
32905      (function spawnCodeParticles() {
32906        var container = document.getElementById('code-particles');
32907        if (!container) return;
32908        var snippets = [
32909          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32910          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32911          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32912          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32913          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32914        ];
32915        var count = 38;
32916        for (var i = 0; i < count; i++) {
32917          (function(idx) {
32918            var el = document.createElement('span');
32919            el.className = 'code-particle';
32920            el.textContent = snippets[idx % snippets.length];
32921            var left = Math.random() * 94 + 2;
32922            var top = Math.random() * 88 + 6;
32923            var dur = (Math.random() * 10 + 9).toFixed(1);
32924            var delay = (Math.random() * 18).toFixed(1);
32925            var rot = (Math.random() * 26 - 13).toFixed(1);
32926            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32927            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32928            container.appendChild(el);
32929          })(i);
32930        }
32931      })();
32932    }());
32933  </script>
32934</body>
32935</html>
32936"##,
32937    ext = "html"
32938)]
32939struct ApiDocsTemplate {
32940    has_api_key: bool,
32941    csp_nonce: String,
32942    version: &'static str,
32943}
32944
32945#[cfg(test)]
32946mod form_config_tests {
32947    use super::*;
32948    use sloc_config::{
32949        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32950    };
32951
32952    fn blank_form() -> AnalyzeForm {
32953        AnalyzeForm {
32954            path: ".".to_string(),
32955            git_repo: None,
32956            git_ref: None,
32957            mixed_line_policy: None,
32958            python_docstrings_as_comments: None,
32959            generated_file_detection: None,
32960            minified_file_detection: None,
32961            vendor_directory_detection: None,
32962            include_lockfiles: None,
32963            binary_file_behavior: None,
32964            output_dir: None,
32965            report_title: None,
32966            report_header_footer: None,
32967            include_globs: None,
32968            exclude_globs: None,
32969            submodule_breakdown: None,
32970            coverage_file: None,
32971            continuation_line_policy: None,
32972            blank_in_block_comment_policy: None,
32973            count_compiler_directives: None,
32974            style_col_threshold: None,
32975            style_analysis_enabled: None,
32976            style_score_threshold: None,
32977            style_lang_scope: None,
32978            cocomo_mode: None,
32979            complexity_alert: None,
32980            exclude_duplicates: None,
32981            activity_window: None,
32982        }
32983    }
32984
32985    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32986        let mut cfg = sloc_config::AppConfig::default();
32987        apply_form_to_config(&mut cfg, form);
32988        cfg
32989    }
32990
32991    // ── activity_window (git hotspots — on by default) ──
32992
32993    #[test]
32994    fn extract_long_commit_picks_super_repo_by_short_prefix() {
32995        // A pretty-printed JSON tail containing several submodule git_commit_long
32996        // values plus the super-repo's; the helper must return the one whose hash
32997        // starts with the known short SHA, ignoring the others and any null value.
32998        let dir = tempfile::tempdir().unwrap();
32999        let path = dir.path().join("result.json");
33000        let body = r#"{
33001  "submodules": [
33002    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
33003    { "git_commit_long": null }
33004  ],
33005  "git_commit_short": "4c2cd9b",
33006  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
33007}"#;
33008        std::fs::write(&path, body).unwrap();
33009        assert_eq!(
33010            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
33011            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
33012        );
33013        // No match for an unrelated short SHA, and empty short yields None.
33014        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
33015        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
33016    }
33017
33018    #[test]
33019    fn activity_window_defaults_on_when_field_blank() {
33020        // Blank form field keeps the config default (90 days).
33021        let cfg = apply(&blank_form());
33022        assert_eq!(cfg.analysis.activity_window_days, Some(90));
33023    }
33024
33025    #[test]
33026    fn activity_window_override_sets_days() {
33027        let mut form = blank_form();
33028        form.activity_window = Some("30".to_string());
33029        let cfg = apply(&form);
33030        assert_eq!(cfg.analysis.activity_window_days, Some(30));
33031    }
33032
33033    #[test]
33034    fn activity_window_zero_disables() {
33035        // An explicit 0 from the form disables hotspots (overrides the default-on).
33036        let mut form = blank_form();
33037        form.activity_window = Some("0".to_string());
33038        let cfg = apply(&form);
33039        assert_eq!(cfg.analysis.activity_window_days, Some(0));
33040    }
33041
33042    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
33043
33044    #[test]
33045    fn python_docstrings_false_when_unchecked() {
33046        // Checkbox absent in form data (unchecked) → field must be false.
33047        let cfg = apply(&blank_form());
33048        assert!(
33049            !cfg.analysis.python_docstrings_as_comments,
33050            "absent python_docstrings_as_comments must map to false"
33051        );
33052    }
33053
33054    #[test]
33055    fn python_docstrings_true_when_checked() {
33056        // Browser sends "on" (no value= attr on the checkbox).
33057        let mut form = blank_form();
33058        form.python_docstrings_as_comments = Some("on".to_string());
33059        let cfg = apply(&form);
33060        assert!(cfg.analysis.python_docstrings_as_comments);
33061    }
33062
33063    #[test]
33064    fn python_docstrings_true_for_any_non_none_value() {
33065        // The handler uses .is_some() — any non-None value means "checked".
33066        let mut form = blank_form();
33067        form.python_docstrings_as_comments = Some("true".to_string());
33068        assert!(apply(&form).analysis.python_docstrings_as_comments);
33069    }
33070
33071    // ── submodule_breakdown (checkbox with value="enabled") ──
33072
33073    #[test]
33074    fn submodule_breakdown_false_when_unchecked() {
33075        let cfg = apply(&blank_form());
33076        assert!(
33077            !cfg.discovery.submodule_breakdown,
33078            "absent submodule_breakdown must map to false"
33079        );
33080    }
33081
33082    #[test]
33083    fn submodule_breakdown_true_when_value_enabled() {
33084        let mut form = blank_form();
33085        form.submodule_breakdown = Some("enabled".to_string());
33086        assert!(apply(&form).discovery.submodule_breakdown);
33087    }
33088
33089    #[test]
33090    fn submodule_breakdown_false_for_wrong_value() {
33091        // If somehow a value other than "enabled" is sent, it must still be false.
33092        let mut form = blank_form();
33093        form.submodule_breakdown = Some("on".to_string());
33094        assert!(
33095            !apply(&form).discovery.submodule_breakdown,
33096            "submodule_breakdown only becomes true for the exact value 'enabled'"
33097        );
33098    }
33099
33100    // ── generated_file_detection (select: "enabled" | "disabled") ──
33101
33102    #[test]
33103    fn generated_detection_true_when_enabled() {
33104        let mut form = blank_form();
33105        form.generated_file_detection = Some("enabled".to_string());
33106        assert!(apply(&form).analysis.generated_file_detection);
33107    }
33108
33109    #[test]
33110    fn generated_detection_false_when_disabled() {
33111        let mut form = blank_form();
33112        form.generated_file_detection = Some("disabled".to_string());
33113        assert!(!apply(&form).analysis.generated_file_detection);
33114    }
33115
33116    #[test]
33117    fn generated_detection_true_when_absent() {
33118        // None != Some("disabled") → true (safe default)
33119        assert!(
33120            apply(&blank_form()).analysis.generated_file_detection,
33121            "absent field must default to true (detection on)"
33122        );
33123    }
33124
33125    // ── minified_file_detection ──
33126
33127    #[test]
33128    fn minified_detection_false_when_disabled() {
33129        let mut form = blank_form();
33130        form.minified_file_detection = Some("disabled".to_string());
33131        assert!(!apply(&form).analysis.minified_file_detection);
33132    }
33133
33134    #[test]
33135    fn minified_detection_true_when_enabled() {
33136        let mut form = blank_form();
33137        form.minified_file_detection = Some("enabled".to_string());
33138        assert!(apply(&form).analysis.minified_file_detection);
33139    }
33140
33141    #[test]
33142    fn minified_detection_true_when_absent() {
33143        assert!(apply(&blank_form()).analysis.minified_file_detection);
33144    }
33145
33146    // ── vendor_directory_detection ──
33147
33148    #[test]
33149    fn vendor_detection_false_when_disabled() {
33150        let mut form = blank_form();
33151        form.vendor_directory_detection = Some("disabled".to_string());
33152        assert!(!apply(&form).analysis.vendor_directory_detection);
33153    }
33154
33155    #[test]
33156    fn vendor_detection_true_when_enabled() {
33157        let mut form = blank_form();
33158        form.vendor_directory_detection = Some("enabled".to_string());
33159        assert!(apply(&form).analysis.vendor_directory_detection);
33160    }
33161
33162    #[test]
33163    fn vendor_detection_true_when_absent() {
33164        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
33165    }
33166
33167    // ── include_lockfiles (select: "disabled" default | "enabled") ──
33168
33169    #[test]
33170    fn lockfiles_false_when_absent() {
33171        // None == Some("enabled") is false → lockfiles off (correct safe default)
33172        assert!(!apply(&blank_form()).analysis.include_lockfiles);
33173    }
33174
33175    #[test]
33176    fn lockfiles_false_when_disabled() {
33177        let mut form = blank_form();
33178        form.include_lockfiles = Some("disabled".to_string());
33179        assert!(!apply(&form).analysis.include_lockfiles);
33180    }
33181
33182    #[test]
33183    fn lockfiles_true_when_enabled() {
33184        let mut form = blank_form();
33185        form.include_lockfiles = Some("enabled".to_string());
33186        assert!(apply(&form).analysis.include_lockfiles);
33187    }
33188
33189    // ── count_compiler_directives ──
33190
33191    #[test]
33192    fn compiler_directives_true_when_absent() {
33193        assert!(
33194            apply(&blank_form()).analysis.count_compiler_directives,
33195            "absent count_compiler_directives must default to true"
33196        );
33197    }
33198
33199    #[test]
33200    fn compiler_directives_true_when_enabled() {
33201        let mut form = blank_form();
33202        form.count_compiler_directives = Some("enabled".to_string());
33203        assert!(apply(&form).analysis.count_compiler_directives);
33204    }
33205
33206    #[test]
33207    fn compiler_directives_false_when_disabled() {
33208        let mut form = blank_form();
33209        form.count_compiler_directives = Some("disabled".to_string());
33210        assert!(!apply(&form).analysis.count_compiler_directives);
33211    }
33212
33213    // ── mixed_line_policy (enum select) ──
33214
33215    #[test]
33216    fn mixed_policy_unchanged_when_absent() {
33217        // None → if-let does nothing → stays at config default (CodeOnly)
33218        assert_eq!(
33219            apply(&blank_form()).analysis.mixed_line_policy,
33220            MixedLinePolicy::CodeOnly
33221        );
33222    }
33223
33224    #[test]
33225    fn mixed_policy_code_only() {
33226        let mut form = blank_form();
33227        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
33228        assert_eq!(
33229            apply(&form).analysis.mixed_line_policy,
33230            MixedLinePolicy::CodeOnly
33231        );
33232    }
33233
33234    #[test]
33235    fn mixed_policy_code_and_comment() {
33236        let mut form = blank_form();
33237        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33238        assert_eq!(
33239            apply(&form).analysis.mixed_line_policy,
33240            MixedLinePolicy::CodeAndComment
33241        );
33242    }
33243
33244    #[test]
33245    fn mixed_policy_comment_only() {
33246        let mut form = blank_form();
33247        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33248        assert_eq!(
33249            apply(&form).analysis.mixed_line_policy,
33250            MixedLinePolicy::CommentOnly
33251        );
33252    }
33253
33254    #[test]
33255    fn mixed_policy_separate_mixed_category() {
33256        let mut form = blank_form();
33257        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33258        assert_eq!(
33259            apply(&form).analysis.mixed_line_policy,
33260            MixedLinePolicy::SeparateMixedCategory
33261        );
33262    }
33263
33264    // ── binary_file_behavior (enum select) ──
33265
33266    #[test]
33267    fn binary_behavior_skip_when_absent() {
33268        assert_eq!(
33269            apply(&blank_form()).analysis.binary_file_behavior,
33270            BinaryFileBehavior::Skip
33271        );
33272    }
33273
33274    #[test]
33275    fn binary_behavior_skip() {
33276        let mut form = blank_form();
33277        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33278        assert_eq!(
33279            apply(&form).analysis.binary_file_behavior,
33280            BinaryFileBehavior::Skip
33281        );
33282    }
33283
33284    #[test]
33285    fn binary_behavior_fail() {
33286        let mut form = blank_form();
33287        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33288        assert_eq!(
33289            apply(&form).analysis.binary_file_behavior,
33290            BinaryFileBehavior::Fail
33291        );
33292    }
33293
33294    // ── continuation_line_policy (enum select) ──
33295
33296    #[test]
33297    fn continuation_policy_each_physical_when_absent() {
33298        assert_eq!(
33299            apply(&blank_form()).analysis.continuation_line_policy,
33300            ContinuationLinePolicy::EachPhysicalLine
33301        );
33302    }
33303
33304    #[test]
33305    fn continuation_policy_collapse_to_logical() {
33306        let mut form = blank_form();
33307        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33308        assert_eq!(
33309            apply(&form).analysis.continuation_line_policy,
33310            ContinuationLinePolicy::CollapseToLogical
33311        );
33312    }
33313
33314    // ── blank_in_block_comment_policy (enum select) ──
33315
33316    #[test]
33317    fn blank_in_block_comment_count_as_comment_when_absent() {
33318        assert_eq!(
33319            apply(&blank_form()).analysis.blank_in_block_comment_policy,
33320            BlankInBlockCommentPolicy::CountAsComment
33321        );
33322    }
33323
33324    #[test]
33325    fn blank_in_block_comment_count_as_blank() {
33326        let mut form = blank_form();
33327        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33328        assert_eq!(
33329            apply(&form).analysis.blank_in_block_comment_policy,
33330            BlankInBlockCommentPolicy::CountAsBlank
33331        );
33332    }
33333
33334    // ── style_col_threshold ──
33335
33336    #[test]
33337    fn style_threshold_80() {
33338        let mut form = blank_form();
33339        form.style_col_threshold = Some("80".to_string());
33340        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33341    }
33342
33343    #[test]
33344    fn style_threshold_100() {
33345        let mut form = blank_form();
33346        form.style_col_threshold = Some("100".to_string());
33347        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33348    }
33349
33350    #[test]
33351    fn style_threshold_120() {
33352        let mut form = blank_form();
33353        form.style_col_threshold = Some("120".to_string());
33354        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33355    }
33356
33357    #[test]
33358    fn style_threshold_invalid_value_leaves_default() {
33359        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
33360        let mut cfg = sloc_config::AppConfig::default();
33361        let mut form = blank_form();
33362        form.style_col_threshold = Some("42".to_string());
33363        apply_form_to_config(&mut cfg, &form);
33364        assert_eq!(
33365            cfg.analysis.style_col_threshold, 80,
33366            "invalid threshold must not change config"
33367        );
33368    }
33369
33370    #[test]
33371    fn style_threshold_non_numeric_leaves_default() {
33372        let mut cfg = sloc_config::AppConfig::default();
33373        let mut form = blank_form();
33374        form.style_col_threshold = Some("large".to_string());
33375        apply_form_to_config(&mut cfg, &form);
33376        assert_eq!(cfg.analysis.style_col_threshold, 80);
33377    }
33378
33379    #[test]
33380    fn style_threshold_zero_leaves_default() {
33381        let mut cfg = sloc_config::AppConfig::default();
33382        let mut form = blank_form();
33383        form.style_col_threshold = Some("0".to_string());
33384        apply_form_to_config(&mut cfg, &form);
33385        assert_eq!(cfg.analysis.style_col_threshold, 80);
33386    }
33387
33388    #[test]
33389    fn style_threshold_absent_leaves_default() {
33390        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33391    }
33392
33393    // ── style_score_threshold ──
33394
33395    #[test]
33396    fn style_score_threshold_zero_when_absent() {
33397        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33398    }
33399
33400    #[test]
33401    fn style_score_threshold_set_to_valid_value() {
33402        let mut form = blank_form();
33403        form.style_score_threshold = Some("70".to_string());
33404        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33405    }
33406
33407    #[test]
33408    fn style_score_threshold_clamps_to_100_when_over() {
33409        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
33410        let mut form = blank_form();
33411        form.style_score_threshold = Some("200".to_string());
33412        assert_eq!(
33413            apply(&form).analysis.style_score_threshold,
33414            100,
33415            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33416        );
33417    }
33418
33419    // ── coverage_file ──
33420
33421    #[test]
33422    fn coverage_file_none_when_absent() {
33423        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33424    }
33425
33426    #[test]
33427    fn coverage_file_none_when_whitespace_only() {
33428        let mut form = blank_form();
33429        form.coverage_file = Some("   ".to_string());
33430        assert!(
33431            apply(&form).analysis.coverage_file.is_none(),
33432            "whitespace-only coverage_file must be treated as None"
33433        );
33434    }
33435
33436    #[test]
33437    fn coverage_file_set_when_non_empty() {
33438        let mut form = blank_form();
33439        form.coverage_file = Some("coverage/lcov.info".to_string());
33440        assert_eq!(
33441            apply(&form).analysis.coverage_file,
33442            Some(std::path::PathBuf::from("coverage/lcov.info"))
33443        );
33444    }
33445
33446    #[test]
33447    fn coverage_file_trims_whitespace() {
33448        let mut form = blank_form();
33449        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
33450        assert_eq!(
33451            apply(&form).analysis.coverage_file,
33452            Some(std::path::PathBuf::from("coverage/lcov.info"))
33453        );
33454    }
33455
33456    // ── report_title ──
33457
33458    #[test]
33459    fn report_title_unchanged_when_absent() {
33460        let original = sloc_config::AppConfig::default().reporting.report_title;
33461        assert_eq!(apply(&blank_form()).reporting.report_title, original);
33462    }
33463
33464    #[test]
33465    fn report_title_unchanged_when_whitespace_only() {
33466        let original = sloc_config::AppConfig::default().reporting.report_title;
33467        let mut form = blank_form();
33468        form.report_title = Some("   ".to_string());
33469        assert_eq!(
33470            apply(&form).reporting.report_title,
33471            original,
33472            "whitespace-only title must not overwrite the default"
33473        );
33474    }
33475
33476    #[test]
33477    fn report_title_updated_and_trimmed() {
33478        let mut form = blank_form();
33479        form.report_title = Some("  My Project  ".to_string());
33480        assert_eq!(apply(&form).reporting.report_title, "My Project");
33481    }
33482
33483    // ── report_header_footer ──
33484
33485    #[test]
33486    fn header_footer_none_when_absent() {
33487        assert!(
33488            apply(&blank_form())
33489                .reporting
33490                .report_header_footer
33491                .is_none()
33492        );
33493    }
33494
33495    #[test]
33496    fn header_footer_none_when_whitespace_only() {
33497        let mut form = blank_form();
33498        form.report_header_footer = Some("  ".to_string());
33499        assert!(apply(&form).reporting.report_header_footer.is_none());
33500    }
33501
33502    #[test]
33503    fn header_footer_set_and_trimmed() {
33504        let mut form = blank_form();
33505        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
33506        assert_eq!(
33507            apply(&form).reporting.report_header_footer,
33508            Some("Confidential — Internal Use".to_string())
33509        );
33510    }
33511
33512    // ── include_globs / exclude_globs ──
33513
33514    #[test]
33515    fn include_globs_empty_when_absent() {
33516        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33517    }
33518
33519    #[test]
33520    fn include_globs_newline_separated() {
33521        let mut form = blank_form();
33522        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33523        assert_eq!(
33524            apply(&form).discovery.include_globs,
33525            vec!["src/**/*.rs", "tests/**/*.rs"]
33526        );
33527    }
33528
33529    #[test]
33530    fn exclude_globs_comma_separated() {
33531        let mut form = blank_form();
33532        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33533        assert_eq!(
33534            apply(&form).discovery.exclude_globs,
33535            vec!["vendor/**", "node_modules/**"]
33536        );
33537    }
33538
33539    #[test]
33540    fn globs_mixed_separators() {
33541        let mut form = blank_form();
33542        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33543        assert_eq!(
33544            apply(&form).discovery.exclude_globs,
33545            vec!["a/**", "b/**", "c/**"]
33546        );
33547    }
33548
33549    // ── split_patterns unit tests ──
33550
33551    #[test]
33552    fn split_patterns_none_is_empty() {
33553        assert!(split_patterns(None).is_empty());
33554    }
33555
33556    #[test]
33557    fn split_patterns_empty_string_is_empty() {
33558        assert!(split_patterns(Some("")).is_empty());
33559    }
33560
33561    #[test]
33562    fn split_patterns_whitespace_only_is_empty() {
33563        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
33564    }
33565
33566    #[test]
33567    fn split_patterns_newlines() {
33568        assert_eq!(
33569            split_patterns(Some("a/**\nb/**\nc/**")),
33570            vec!["a/**", "b/**", "c/**"]
33571        );
33572    }
33573
33574    #[test]
33575    fn split_patterns_commas() {
33576        assert_eq!(
33577            split_patterns(Some("a/**,b/**,c/**")),
33578            vec!["a/**", "b/**", "c/**"]
33579        );
33580    }
33581
33582    #[test]
33583    fn split_patterns_mixed() {
33584        assert_eq!(
33585            split_patterns(Some("a/**\nb/**,c/**")),
33586            vec!["a/**", "b/**", "c/**"]
33587        );
33588    }
33589
33590    #[test]
33591    fn split_patterns_trims_whitespace() {
33592        assert_eq!(
33593            split_patterns(Some("  a/**  \n  b/**  ")),
33594            vec!["a/**", "b/**"]
33595        );
33596    }
33597
33598    #[test]
33599    fn split_patterns_filters_empty_entries() {
33600        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33601    }
33602
33603    #[test]
33604    fn split_patterns_single_entry() {
33605        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33606    }
33607}
33608
33609#[cfg(test)]
33610mod utility_tests {
33611    use super::*;
33612    use std::net::IpAddr;
33613    use std::time::Duration;
33614
33615    // ── sanitize_project_label ────────────────────────────────────────────────
33616
33617    #[test]
33618    fn sanitize_simple_name() {
33619        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33620    }
33621
33622    #[test]
33623    fn sanitize_uppercased_lowercased() {
33624        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33625    }
33626
33627    #[test]
33628    fn sanitize_path_extracts_filename() {
33629        assert_eq!(
33630            sanitize_project_label("/home/user/my-project"),
33631            "my-project"
33632        );
33633    }
33634
33635    #[test]
33636    fn sanitize_path_uses_last_component() {
33637        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33638    }
33639
33640    #[test]
33641    fn sanitize_spaces_become_hyphens() {
33642        assert_eq!(sanitize_project_label("my project"), "my-project");
33643    }
33644
33645    #[test]
33646    fn sanitize_non_ascii_become_hyphens() {
33647        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33648    }
33649
33650    #[test]
33651    fn sanitize_all_special_chars_gives_project() {
33652        assert_eq!(sanitize_project_label("!@#$%^"), "project");
33653    }
33654
33655    #[test]
33656    fn sanitize_empty_string_gives_project() {
33657        assert_eq!(sanitize_project_label(""), "project");
33658    }
33659
33660    #[test]
33661    fn sanitize_leading_trailing_hyphens_stripped() {
33662        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33663    }
33664
33665    #[test]
33666    fn sanitize_alphanumeric_preserved() {
33667        assert_eq!(sanitize_project_label("repo123"), "repo123");
33668    }
33669
33670    #[test]
33671    fn sanitize_dots_become_hyphens() {
33672        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33673    }
33674
33675    #[test]
33676    fn sanitize_mixed_slashes_uses_filename() {
33677        // The Windows path separator — on all platforms Path::file_name still works
33678        assert_eq!(sanitize_project_label("project-name"), "project-name");
33679    }
33680
33681    // ── IpRateLimiter ─────────────────────────────────────────────────────────
33682
33683    #[test]
33684    fn rate_limiter_allows_first_request() {
33685        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33686        let ip: IpAddr = "127.0.0.1".parse().unwrap();
33687        assert!(rl.is_allowed(ip));
33688    }
33689
33690    #[test]
33691    fn rate_limiter_blocks_after_limit_reached() {
33692        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33693        let ip: IpAddr = "10.0.0.1".parse().unwrap();
33694        assert!(rl.is_allowed(ip));
33695        assert!(rl.is_allowed(ip));
33696        assert!(rl.is_allowed(ip));
33697        assert!(!rl.is_allowed(ip), "4th request must be blocked");
33698    }
33699
33700    #[test]
33701    fn rate_limiter_allows_requests_up_to_limit() {
33702        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33703        let ip: IpAddr = "10.0.0.2".parse().unwrap();
33704        for _ in 0..5 {
33705            assert!(rl.is_allowed(ip));
33706        }
33707        assert!(!rl.is_allowed(ip), "6th request must be blocked");
33708    }
33709
33710    #[test]
33711    fn rate_limiter_different_ips_are_independent() {
33712        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33713        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33714        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33715        assert!(rl.is_allowed(ip1));
33716        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33717        assert!(rl.is_allowed(ip2), "ip2 must be independent");
33718    }
33719
33720    #[test]
33721    fn rate_limiter_auth_failure_not_locked_below_threshold() {
33722        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33723        let ip: IpAddr = "10.0.0.3".parse().unwrap();
33724        rl.record_auth_failure(ip);
33725        rl.record_auth_failure(ip);
33726        assert!(
33727            !rl.is_auth_locked_out(ip),
33728            "not locked at 2 failures when threshold is 3"
33729        );
33730    }
33731
33732    #[test]
33733    fn rate_limiter_auth_failure_locked_at_threshold() {
33734        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33735        let ip: IpAddr = "10.0.0.4".parse().unwrap();
33736        rl.record_auth_failure(ip);
33737        rl.record_auth_failure(ip);
33738        rl.record_auth_failure(ip);
33739        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33740    }
33741
33742    #[test]
33743    fn rate_limiter_auth_failure_different_ips_independent() {
33744        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33745        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33746        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33747        rl.record_auth_failure(ip1);
33748        rl.record_auth_failure(ip1);
33749        assert!(rl.is_auth_locked_out(ip1));
33750        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33751    }
33752
33753    #[test]
33754    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33755        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33756        let ip: IpAddr = "127.0.0.2".parse().unwrap();
33757        for _ in 0..100 {
33758            assert!(rl.is_allowed(ip));
33759        }
33760    }
33761
33762    // ── strip_unc_prefix ──────────────────────────────────────────────────────
33763
33764    #[test]
33765    fn strip_unc_plain_path_unchanged() {
33766        let p = PathBuf::from("C:\\Users\\user\\project");
33767        let result = strip_unc_prefix(p.clone());
33768        assert_eq!(result, p);
33769    }
33770
33771    #[test]
33772    fn strip_unc_with_drive_prefix_stripped() {
33773        let p = PathBuf::from(r"\\?\C:\Users\user\project");
33774        let result = strip_unc_prefix(p);
33775        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33776    }
33777
33778    #[test]
33779    fn strip_unc_with_network_prefix_stripped() {
33780        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33781        let result = strip_unc_prefix(p);
33782        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33783    }
33784
33785    #[test]
33786    fn strip_unc_linux_path_unchanged() {
33787        let p = PathBuf::from("/home/user/project");
33788        let result = strip_unc_prefix(p.clone());
33789        assert_eq!(result, p);
33790    }
33791
33792    // ── remote_to_commit_url ──────────────────────────────────────────────────
33793
33794    #[test]
33795    fn remote_to_commit_url_github_https() {
33796        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33797        assert_eq!(
33798            url,
33799            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33800        );
33801    }
33802
33803    #[test]
33804    fn remote_to_commit_url_github_ssh() {
33805        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33806        assert_eq!(
33807            url,
33808            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33809        );
33810    }
33811
33812    #[test]
33813    fn remote_to_commit_url_gitlab_uses_dash_commit() {
33814        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33815        assert_eq!(
33816            url,
33817            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33818        );
33819    }
33820
33821    #[test]
33822    fn remote_to_commit_url_bitbucket_uses_commits() {
33823        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33824        assert_eq!(
33825            url,
33826            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33827        );
33828    }
33829
33830    #[test]
33831    fn remote_to_commit_url_unknown_scheme_returns_none() {
33832        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33833        assert!(url.is_none());
33834    }
33835
33836    #[test]
33837    fn remote_to_commit_url_ssh_gitlab() {
33838        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33839        assert!(url.is_some());
33840        let u = url.unwrap();
33841        assert!(
33842            u.contains("/-/commit/sha123"),
33843            "gitlab ssh must use /-/commit/"
33844        );
33845    }
33846
33847    // ── git_clone_dest ────────────────────────────────────────────────────────
33848
33849    #[test]
33850    fn git_clone_dest_github_url_produces_safe_name() {
33851        let dir = PathBuf::from("/tmp/clones");
33852        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33853        let name = dest.file_name().unwrap().to_string_lossy();
33854        assert!(!name.is_empty());
33855        assert!(
33856            name.chars()
33857                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33858            "clone dest must only contain safe chars, got: {name}"
33859        );
33860    }
33861
33862    #[test]
33863    fn git_clone_dest_is_inside_clones_dir() {
33864        let dir = PathBuf::from("/tmp/clones");
33865        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33866        assert!(
33867            dest.starts_with(&dir),
33868            "clone dest must be inside clones_dir"
33869        );
33870    }
33871
33872    #[test]
33873    fn git_clone_dest_truncates_to_80_chars_max() {
33874        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33875        let dir = PathBuf::from("/tmp/clones");
33876        let dest = git_clone_dest(&long_url, &dir);
33877        let name = dest.file_name().unwrap().to_string_lossy();
33878        assert!(
33879            name.len() <= 80,
33880            "clone dest name must be at most 80 chars, got {} chars: {name}",
33881            name.len()
33882        );
33883    }
33884
33885    #[test]
33886    fn git_clone_dest_special_chars_replaced_with_underscore() {
33887        let dir = PathBuf::from("/tmp/clones");
33888        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33889        let name = dest.file_name().unwrap().to_string_lossy();
33890        assert!(
33891            !name.contains('@') && !name.contains(':') && !name.contains('/'),
33892            "special chars must be replaced in clone dest, got: {name}"
33893        );
33894    }
33895
33896    #[test]
33897    fn git_clone_dest_different_urls_differ() {
33898        let dir = PathBuf::from("/tmp/clones");
33899        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33900        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33901        assert_ne!(
33902            a, b,
33903            "different repos must produce different clone dest names"
33904        );
33905    }
33906
33907    #[test]
33908    fn git_clone_dest_same_url_same_result() {
33909        let dir = PathBuf::from("/tmp/clones");
33910        let url = "https://github.com/owner/repo.git";
33911        assert_eq!(
33912            git_clone_dest(url, &dir),
33913            git_clone_dest(url, &dir),
33914            "same URL must always give same clone dest"
33915        );
33916    }
33917
33918    // ── fmt_delta ─────────────────────────────────────────────────────────────
33919
33920    #[test]
33921    fn fmt_delta_positive_has_plus_prefix() {
33922        assert_eq!(fmt_delta(5), "+5");
33923    }
33924
33925    #[test]
33926    fn fmt_delta_negative_no_plus_prefix() {
33927        assert_eq!(fmt_delta(-3), "-3");
33928    }
33929
33930    #[test]
33931    fn fmt_delta_zero() {
33932        assert_eq!(fmt_delta(0), "0");
33933    }
33934
33935    // ── delta_class ───────────────────────────────────────────────────────────
33936
33937    #[test]
33938    fn delta_class_positive_is_pos() {
33939        assert_eq!(delta_class(1), "pos");
33940    }
33941
33942    #[test]
33943    fn delta_class_negative_is_neg() {
33944        assert_eq!(delta_class(-1), "neg");
33945    }
33946
33947    #[test]
33948    fn delta_class_zero_is_zero_class() {
33949        assert_eq!(delta_class(0), "zero");
33950    }
33951
33952    // ── fmt_pct ───────────────────────────────────────────────────────────────
33953
33954    #[test]
33955    fn fmt_pct_zero_baseline_returns_em_dash() {
33956        assert_eq!(fmt_pct(100, 0), "\u{2014}");
33957    }
33958
33959    #[test]
33960    fn fmt_pct_positive_delta_has_plus_sign() {
33961        let result = fmt_pct(10, 100);
33962        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33963    }
33964
33965    #[test]
33966    fn fmt_pct_negative_delta_no_plus_sign() {
33967        let result = fmt_pct(-10, 100);
33968        assert!(!result.starts_with('+'), "unexpected + in: {result}");
33969        assert!(result.contains('%'));
33970    }
33971
33972    #[test]
33973    fn fmt_pct_near_zero_returns_pm_zero() {
33974        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33975    }
33976
33977    // ── summary_delta ─────────────────────────────────────────────────────────
33978
33979    #[test]
33980    fn summary_delta_no_prev_returns_dash_na() {
33981        let (display, class) = summary_delta(10, None);
33982        assert_eq!(display, "\u{2014}");
33983        assert_eq!(class, "na");
33984    }
33985
33986    #[test]
33987    fn summary_delta_increase_is_positive() {
33988        let (display, class) = summary_delta(15, Some(10));
33989        assert_eq!(display, "+5");
33990        assert_eq!(class, "pos");
33991    }
33992
33993    #[test]
33994    fn summary_delta_decrease_is_negative() {
33995        let (display, class) = summary_delta(5, Some(10));
33996        assert_eq!(display, "-5");
33997        assert_eq!(class, "neg");
33998    }
33999
34000    // ── nth_weekday_of_month ──────────────────────────────────────────────────
34001
34002    #[test]
34003    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
34004        use chrono::Datelike;
34005        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
34006        assert_eq!(d.year(), 2024);
34007        assert_eq!(d.month(), 1);
34008        assert_eq!(d.weekday(), chrono::Weekday::Mon);
34009        assert!(d.day() <= 7);
34010    }
34011
34012    #[test]
34013    fn nth_weekday_second_sunday_march_2024_is_10th() {
34014        use chrono::Datelike;
34015        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
34016        assert_eq!(d.weekday(), chrono::Weekday::Sun);
34017        assert_eq!(d.month(), 3);
34018        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
34019    }
34020
34021    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
34022
34023    #[test]
34024    fn is_pacific_dst_july_is_true() {
34025        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34026        assert!(is_pacific_dst(dt), "July must be PDT");
34027    }
34028
34029    #[test]
34030    fn is_pacific_dst_january_is_false() {
34031        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34032        assert!(!is_pacific_dst(dt), "January must be PST");
34033    }
34034
34035    #[test]
34036    fn fmt_la_time_summer_shows_pdt() {
34037        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34038        let result = fmt_la_time(dt);
34039        assert!(
34040            result.ends_with("PDT"),
34041            "summer must use PDT, got: {result}"
34042        );
34043    }
34044
34045    #[test]
34046    fn fmt_la_time_winter_shows_pst() {
34047        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34048        let result = fmt_la_time(dt);
34049        assert!(
34050            result.ends_with("PST"),
34051            "winter must use PST, got: {result}"
34052        );
34053    }
34054
34055    #[test]
34056    fn fmt_la_time_meta_summer_shows_pdt() {
34057        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
34058        let result = fmt_la_time_meta(dt);
34059        assert!(
34060            result.ends_with("PDT"),
34061            "meta summer must use PDT, got: {result}"
34062        );
34063    }
34064
34065    #[test]
34066    fn fmt_la_time_meta_winter_shows_pst() {
34067        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
34068        let result = fmt_la_time_meta(dt);
34069        assert!(
34070            result.ends_with("PST"),
34071            "meta winter must use PST, got: {result}"
34072        );
34073    }
34074
34075    // ── fmt_git_date ──────────────────────────────────────────────────────────
34076
34077    #[test]
34078    fn fmt_git_date_valid_iso_returns_some() {
34079        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
34080    }
34081
34082    #[test]
34083    fn fmt_git_date_invalid_returns_none() {
34084        assert!(fmt_git_date("not-a-date").is_none());
34085    }
34086
34087    // ── format_number ─────────────────────────────────────────────────────────
34088
34089    #[test]
34090    fn format_number_zero() {
34091        assert_eq!(format_number(0), "0");
34092    }
34093
34094    #[test]
34095    fn format_number_three_digits_no_comma() {
34096        assert_eq!(format_number(999), "999");
34097    }
34098
34099    #[test]
34100    fn format_number_four_digits_has_comma() {
34101        assert_eq!(format_number(1000), "1,000");
34102    }
34103
34104    #[test]
34105    fn format_number_seven_digits_two_commas() {
34106        assert_eq!(format_number(1_234_567), "1,234,567");
34107    }
34108
34109    #[test]
34110    fn format_number_one_million() {
34111        assert_eq!(format_number(1_000_000), "1,000,000");
34112    }
34113
34114    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
34115
34116    #[test]
34117    fn badge_text_px_empty_is_zero() {
34118        assert_eq!(badge_text_px(""), 0);
34119    }
34120
34121    #[test]
34122    fn badge_text_px_narrow_chars_smaller_than_normal() {
34123        assert!(
34124            badge_text_px("if") < badge_text_px("ab"),
34125            "'if' must be narrower than 'ab'"
34126        );
34127    }
34128
34129    #[test]
34130    fn badge_text_px_m_is_wider_than_a() {
34131        assert!(
34132            badge_text_px("m") > badge_text_px("a"),
34133            "'m' must be wider than 'a'"
34134        );
34135    }
34136
34137    #[test]
34138    fn render_badge_svg_contains_label_and_value() {
34139        let svg = render_badge_svg("coverage", "95%", "#4c1");
34140        assert!(svg.contains("coverage") && svg.contains("95%"));
34141    }
34142
34143    #[test]
34144    fn render_badge_svg_contains_color() {
34145        let svg = render_badge_svg("sloc", "12K", "#e05d44");
34146        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
34147    }
34148
34149    #[test]
34150    fn render_badge_svg_escapes_ampersand_in_label() {
34151        let svg = render_badge_svg("test&label", "ok", "#4c1");
34152        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
34153    }
34154
34155    // ── build_pdf_filename ────────────────────────────────────────────────────
34156
34157    #[test]
34158    fn build_pdf_filename_slugifies_title() {
34159        let name = build_pdf_filename("My Project Report", "abc-def-1234");
34160        assert!(
34161            name.starts_with("my_project_report_")
34162                && std::path::Path::new(&name)
34163                    .extension()
34164                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34165        );
34166    }
34167
34168    #[test]
34169    fn build_pdf_filename_uses_last_run_id_segment() {
34170        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
34171        assert!(name.contains("ABCD"), "must use last segment of run_id");
34172    }
34173
34174    #[test]
34175    fn build_pdf_filename_empty_title_uses_report_prefix() {
34176        let name = build_pdf_filename("", "abc-def-9999");
34177        assert!(
34178            name.starts_with("report_")
34179                && std::path::Path::new(&name)
34180                    .extension()
34181                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34182        );
34183    }
34184
34185    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
34186
34187    #[test]
34188    fn swap_chart_js_replaces_inline_block() {
34189        let html = "<html><head><script>// inline source</script></head><body></body></html>";
34190        let result = swap_inline_chart_js_for_static(html.to_string());
34191        assert!(result.contains(r#"src="/static/chart-report.js""#));
34192        assert!(!result.contains("inline source"));
34193    }
34194
34195    #[test]
34196    fn swap_chart_js_no_head_returns_unchanged() {
34197        let html = "<body>no head here</body>";
34198        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
34199    }
34200
34201    #[test]
34202    fn swap_chart_js_no_script_in_head_unchanged() {
34203        let html = "<html><head><style>.x{}</style></head><body></body></html>";
34204        let result = swap_inline_chart_js_for_static(html.to_string());
34205        assert!(!result.contains("chart-report.js"));
34206    }
34207
34208    // ── patch_html_nonce ──────────────────────────────────────────────────────
34209
34210    #[test]
34211    fn patch_html_nonce_replaces_old_nonce() {
34212        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
34213        let result = patch_html_nonce(html, "new-nonce-456");
34214        assert!(result.contains(r#"nonce="new-nonce-456""#));
34215        assert!(!result.contains("old-nonce-123"));
34216    }
34217
34218    #[test]
34219    fn patch_html_nonce_injects_into_bare_style() {
34220        let html = "<style>body{color:red;}</style>";
34221        let result = patch_html_nonce(html, "fresh-nonce");
34222        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
34223    }
34224
34225    #[test]
34226    fn patch_html_nonce_injects_into_bare_script() {
34227        let html = "<script>console.log(1);</script>";
34228        let result = patch_html_nonce(html, "abc");
34229        assert!(result.contains(r#"<script nonce="abc">"#));
34230    }
34231
34232    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
34233
34234    #[test]
34235    fn is_html_report_file_result_html_matches() {
34236        let dir = tempfile::tempdir().unwrap();
34237        let path = dir.path().join("result_20240101.html");
34238        std::fs::write(&path, b"<html></html>").unwrap();
34239        assert!(is_html_report_file(&path));
34240    }
34241
34242    #[test]
34243    fn is_html_report_file_report_html_matches() {
34244        let dir = tempfile::tempdir().unwrap();
34245        let path = dir.path().join("report_abc.html");
34246        std::fs::write(&path, b"<html></html>").unwrap();
34247        assert!(is_html_report_file(&path));
34248    }
34249
34250    #[test]
34251    fn is_html_report_file_index_html_does_not_match() {
34252        let dir = tempfile::tempdir().unwrap();
34253        let path = dir.path().join("index.html");
34254        std::fs::write(&path, b"<html></html>").unwrap();
34255        assert!(!is_html_report_file(&path));
34256    }
34257
34258    #[test]
34259    fn is_html_report_file_nonexistent_returns_false() {
34260        assert!(!is_html_report_file(Path::new(
34261            "/nonexistent/result_xyz.html"
34262        )));
34263    }
34264
34265    #[test]
34266    fn find_html_report_in_dir_finds_result_html() {
34267        let dir = tempfile::tempdir().unwrap();
34268        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34269        assert!(find_html_report_in_dir(dir.path()).is_some());
34270    }
34271
34272    #[test]
34273    fn find_html_report_in_dir_empty_returns_none() {
34274        let dir = tempfile::tempdir().unwrap();
34275        assert!(find_html_report_in_dir(dir.path()).is_none());
34276    }
34277
34278    #[test]
34279    fn find_html_report_in_tree_finds_in_subdir() {
34280        let dir = tempfile::tempdir().unwrap();
34281        let subdir = dir.path().join("run-001");
34282        std::fs::create_dir_all(&subdir).unwrap();
34283        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34284        assert!(find_html_report_in_tree(dir.path()).is_some());
34285    }
34286
34287    // ── derive_project_label ──────────────────────────────────────────────────
34288
34289    #[test]
34290    fn derive_project_label_with_git_repo_and_ref() {
34291        let label = derive_project_label(
34292            Some("https://github.com/owner/my-repo.git"),
34293            Some("main"),
34294            "/fallback/path",
34295        );
34296        assert!(!label.is_empty(), "label must not be empty");
34297        assert!(
34298            label.contains("my") || label.contains("repo"),
34299            "got: {label}"
34300        );
34301    }
34302
34303    #[test]
34304    fn derive_project_label_fallback_to_path() {
34305        let label = derive_project_label(None, None, "/path/to/myproject");
34306        assert_eq!(label, "myproject");
34307    }
34308
34309    #[test]
34310    fn derive_project_label_empty_git_fields_use_path() {
34311        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34312        assert_eq!(label, "cool-app");
34313    }
34314
34315    // ── derive_file_stem ──────────────────────────────────────────────────────
34316
34317    #[test]
34318    fn derive_file_stem_with_commit_appends_sha() {
34319        assert_eq!(
34320            derive_file_stem("myproject", Some("a1b2c3")),
34321            "myproject_a1b2c3"
34322        );
34323    }
34324
34325    #[test]
34326    fn derive_file_stem_without_commit_returns_label() {
34327        assert_eq!(derive_file_stem("myproject", None), "myproject");
34328    }
34329
34330    #[test]
34331    fn derive_file_stem_empty_commit_returns_label() {
34332        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34333    }
34334
34335    // ── split_patterns ────────────────────────────────────────────────────────
34336
34337    #[test]
34338    fn split_patterns_none_is_empty() {
34339        assert!(split_patterns(None).is_empty());
34340    }
34341
34342    #[test]
34343    fn split_patterns_empty_string_is_empty() {
34344        assert!(split_patterns(Some("")).is_empty());
34345    }
34346
34347    #[test]
34348    fn split_patterns_comma_separated() {
34349        assert_eq!(
34350            split_patterns(Some("foo,bar,baz")),
34351            vec!["foo", "bar", "baz"]
34352        );
34353    }
34354
34355    #[test]
34356    fn split_patterns_newline_separated() {
34357        assert_eq!(
34358            split_patterns(Some("foo\nbar\nbaz")),
34359            vec!["foo", "bar", "baz"]
34360        );
34361    }
34362
34363    #[test]
34364    fn split_patterns_trims_whitespace() {
34365        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
34366    }
34367
34368    // ── make_git_label ────────────────────────────────────────────────────────
34369
34370    #[test]
34371    fn make_git_label_empty_repo_empty_result() {
34372        assert_eq!(make_git_label("", "main"), "");
34373    }
34374
34375    #[test]
34376    fn make_git_label_empty_ref_empty_result() {
34377        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34378    }
34379
34380    #[test]
34381    fn make_git_label_basic_format() {
34382        assert_eq!(
34383            make_git_label("https://github.com/owner/my-repo.git", "main"),
34384            "my-repo_at_main_sloc"
34385        );
34386    }
34387
34388    #[test]
34389    fn make_git_label_slash_in_ref_replaced() {
34390        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34391        assert!(
34392            !label.contains('/'),
34393            "slash in ref must be replaced: {label}"
34394        );
34395    }
34396
34397    // ── format_dir_size ───────────────────────────────────────────────────────
34398
34399    #[test]
34400    fn format_dir_size_bytes() {
34401        assert_eq!(format_dir_size(500), "500 B");
34402    }
34403
34404    #[test]
34405    fn format_dir_size_kilobytes() {
34406        assert_eq!(format_dir_size(2048), "2 KB");
34407    }
34408
34409    #[test]
34410    fn format_dir_size_megabytes() {
34411        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34412    }
34413
34414    #[test]
34415    fn format_dir_size_gigabytes() {
34416        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34417    }
34418
34419    #[test]
34420    fn format_dir_size_zero() {
34421        assert_eq!(format_dir_size(0), "0 B");
34422    }
34423
34424    // ── civil_from_days ───────────────────────────────────────────────────────
34425
34426    #[test]
34427    fn civil_from_days_epoch() {
34428        assert_eq!(civil_from_days(0), (1970, 1, 1));
34429    }
34430
34431    #[test]
34432    fn civil_from_days_one_year_later() {
34433        assert_eq!(civil_from_days(365), (1971, 1, 1));
34434    }
34435
34436    #[test]
34437    fn civil_from_days_31_days_is_feb_1_1970() {
34438        assert_eq!(civil_from_days(31), (1970, 2, 1));
34439    }
34440
34441    // ── format_system_time ────────────────────────────────────────────────────
34442
34443    #[test]
34444    fn format_system_time_unix_epoch_formats_correctly() {
34445        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34446    }
34447
34448    #[test]
34449    fn format_system_time_31_days_after_epoch() {
34450        let t = UNIX_EPOCH + Duration::from_hours(744);
34451        assert_eq!(format_system_time(t), "1970-02-01 00:00");
34452    }
34453
34454    #[test]
34455    fn format_system_time_before_epoch_returns_dash() {
34456        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34457            assert_eq!(format_system_time(before), "-");
34458        }
34459    }
34460
34461    // ── detect_language_name ──────────────────────────────────────────────────
34462
34463    #[test]
34464    fn detect_language_name_dot_c() {
34465        assert_eq!(detect_language_name("main.c"), Some("C"));
34466    }
34467
34468    #[test]
34469    fn detect_language_name_dot_h() {
34470        assert_eq!(detect_language_name("defs.h"), Some("C"));
34471    }
34472
34473    #[test]
34474    fn detect_language_name_dot_cpp() {
34475        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34476    }
34477
34478    #[test]
34479    fn detect_language_name_dot_py() {
34480        assert_eq!(detect_language_name("script.py"), Some("Python"));
34481    }
34482
34483    #[test]
34484    fn detect_language_name_dot_ps1() {
34485        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34486    }
34487
34488    #[test]
34489    fn detect_language_name_dot_cs() {
34490        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34491    }
34492
34493    #[test]
34494    fn detect_language_name_dot_sh() {
34495        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34496    }
34497
34498    #[test]
34499    fn detect_language_name_unknown_txt() {
34500        assert_eq!(detect_language_name("notes.txt"), None);
34501    }
34502
34503    // ── language_icon_file ────────────────────────────────────────────────────
34504
34505    #[test]
34506    fn language_icon_file_c() {
34507        assert_eq!(language_icon_file("C"), Some("c.png"));
34508    }
34509
34510    #[test]
34511    fn language_icon_file_python() {
34512        assert_eq!(language_icon_file("Python"), Some("python.png"));
34513    }
34514
34515    #[test]
34516    fn language_icon_file_dockerfile() {
34517        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34518    }
34519
34520    #[test]
34521    fn language_icon_file_rust_is_none() {
34522        assert!(language_icon_file("Rust").is_none());
34523    }
34524
34525    #[test]
34526    fn language_icon_file_unknown_is_none() {
34527        assert!(language_icon_file("Fortran").is_none());
34528    }
34529
34530    // ── language_inline_svg ───────────────────────────────────────────────────
34531
34532    #[test]
34533    fn language_inline_svg_rust_is_svg() {
34534        let svg = language_inline_svg("Rust").unwrap();
34535        assert!(svg.starts_with("<svg"));
34536    }
34537
34538    #[test]
34539    fn language_inline_svg_typescript_is_some() {
34540        assert!(language_inline_svg("TypeScript").is_some());
34541    }
34542
34543    #[test]
34544    fn language_inline_svg_unknown_is_none() {
34545        assert!(language_inline_svg("Fortran").is_none());
34546    }
34547
34548    // ── classify_preview_file ─────────────────────────────────────────────────
34549
34550    #[test]
34551    fn classify_preview_file_c_supported() {
34552        assert!(matches!(
34553            classify_preview_file("main.c"),
34554            PreviewKind::Supported
34555        ));
34556    }
34557
34558    #[test]
34559    fn classify_preview_file_python_supported() {
34560        assert!(matches!(
34561            classify_preview_file("script.py"),
34562            PreviewKind::Supported
34563        ));
34564    }
34565
34566    #[test]
34567    fn classify_preview_file_png_skipped() {
34568        assert!(matches!(
34569            classify_preview_file("image.png"),
34570            PreviewKind::Skipped
34571        ));
34572    }
34573
34574    #[test]
34575    fn classify_preview_file_zip_skipped() {
34576        assert!(matches!(
34577            classify_preview_file("archive.zip"),
34578            PreviewKind::Skipped
34579        ));
34580    }
34581
34582    #[test]
34583    fn classify_preview_file_min_js_skipped() {
34584        assert!(matches!(
34585            classify_preview_file("bundle.min.js"),
34586            PreviewKind::Skipped
34587        ));
34588    }
34589
34590    #[test]
34591    fn classify_preview_file_rs_unsupported() {
34592        assert!(matches!(
34593            classify_preview_file("main.rs"),
34594            PreviewKind::Unsupported
34595        ));
34596    }
34597
34598    // ── preview_relative_path ─────────────────────────────────────────────────
34599
34600    #[test]
34601    fn preview_relative_path_strips_root() {
34602        let root = PathBuf::from("/project");
34603        let path = PathBuf::from("/project/src/main.c");
34604        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34605    }
34606
34607    #[test]
34608    fn preview_relative_path_unrooted_includes_filename() {
34609        let root = PathBuf::from("/other");
34610        let path = PathBuf::from("/project/src/main.c");
34611        let result = preview_relative_path(&root, &path);
34612        assert!(result.contains("main.c"));
34613    }
34614
34615    #[test]
34616    fn preview_relative_path_uses_forward_slashes() {
34617        let root = PathBuf::from("/project");
34618        let path = PathBuf::from("/project/a/b/c.py");
34619        assert!(!preview_relative_path(&root, &path).contains('\\'));
34620    }
34621
34622    // ── wildcard_match ────────────────────────────────────────────────────────
34623
34624    #[test]
34625    fn wildcard_match_exact_equal() {
34626        assert!(wildcard_match("foo", "foo"));
34627    }
34628
34629    #[test]
34630    fn wildcard_match_exact_mismatch() {
34631        assert!(!wildcard_match("foo", "bar"));
34632    }
34633
34634    #[test]
34635    fn wildcard_match_star_suffix() {
34636        assert!(wildcard_match("*.rs", "main.rs"));
34637    }
34638
34639    #[test]
34640    fn wildcard_match_star_middle_requires_suffix() {
34641        assert!(!wildcard_match("a*b", "ac"));
34642    }
34643
34644    #[test]
34645    fn wildcard_match_question_mark_single_char() {
34646        assert!(wildcard_match("f?o", "foo"));
34647    }
34648
34649    #[test]
34650    fn wildcard_match_double_star_nested() {
34651        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34652    }
34653
34654    #[test]
34655    fn wildcard_match_star_directory_entry() {
34656        assert!(wildcard_match("vendor/*", "vendor/crate"));
34657    }
34658
34659    #[test]
34660    fn wildcard_match_no_cross_prefix() {
34661        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34662    }
34663
34664    // ── should_skip_preview_directory ────────────────────────────────────────
34665
34666    #[test]
34667    fn should_skip_empty_relative_is_false() {
34668        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34669    }
34670
34671    #[test]
34672    fn should_skip_matching_pattern() {
34673        assert!(should_skip_preview_directory(
34674            "vendor",
34675            &["vendor".to_string()]
34676        ));
34677    }
34678
34679    #[test]
34680    fn should_skip_non_matching() {
34681        assert!(!should_skip_preview_directory(
34682            "src",
34683            &["vendor".to_string()]
34684        ));
34685    }
34686
34687    #[test]
34688    fn should_skip_wildcard_prefix() {
34689        assert!(should_skip_preview_directory(
34690            "target/debug",
34691            &["target*".to_string()]
34692        ));
34693    }
34694
34695    // ── should_include_preview_file ───────────────────────────────────────────
34696
34697    #[test]
34698    fn should_include_empty_relative_always_true() {
34699        assert!(should_include_preview_file("", &[], &[]));
34700    }
34701
34702    #[test]
34703    fn should_include_no_patterns_includes_all() {
34704        assert!(should_include_preview_file("src/main.c", &[], &[]));
34705    }
34706
34707    #[test]
34708    fn should_include_excluded_by_pattern() {
34709        assert!(!should_include_preview_file(
34710            "vendor/lib.c",
34711            &[],
34712            &["vendor/*".to_string()]
34713        ));
34714    }
34715
34716    #[test]
34717    fn should_include_include_pattern_filters() {
34718        assert!(!should_include_preview_file(
34719            "tests/test_foo.c",
34720            &["src/*".to_string()],
34721            &[]
34722        ));
34723    }
34724
34725    // ── escape_html ───────────────────────────────────────────────────────────
34726
34727    #[test]
34728    fn escape_html_ampersand() {
34729        assert_eq!(escape_html("a&b"), "a&amp;b");
34730    }
34731
34732    #[test]
34733    fn escape_html_angle_brackets() {
34734        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
34735    }
34736
34737    #[test]
34738    fn escape_html_double_quote() {
34739        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
34740    }
34741
34742    #[test]
34743    fn escape_html_single_quote() {
34744        assert_eq!(escape_html("it's"), "it&#39;s");
34745    }
34746
34747    #[test]
34748    fn escape_html_plain_text_unchanged() {
34749        assert_eq!(escape_html("hello world"), "hello world");
34750    }
34751
34752    // ── sum_added / removed / unmodified code lines ───────────────────────────
34753
34754    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34755        sloc_core::ScanComparison {
34756            summary: sloc_core::SummaryDelta {
34757                baseline_run_id: "base".to_string(),
34758                current_run_id: "curr".to_string(),
34759                baseline_timestamp: chrono::Utc::now(),
34760                current_timestamp: chrono::Utc::now(),
34761                baseline_files: 4,
34762                current_files: 4,
34763                files_analyzed_delta: 0,
34764                baseline_code: 330,
34765                current_code: 400,
34766                code_lines_delta: 70,
34767                baseline_comments: 0,
34768                current_comments: 0,
34769                comment_lines_delta: 0,
34770                blank_lines_delta: 0,
34771                total_lines_delta: 70,
34772                coverage_lines_hit_delta: None,
34773                coverage_line_pct_delta: None,
34774                baseline_coverage_line_pct: None,
34775                current_coverage_line_pct: None,
34776            },
34777            file_deltas: vec![
34778                sloc_core::FileDelta {
34779                    relative_path: "added.rs".to_string(),
34780                    language: Some("Rust".to_string()),
34781                    status: FileChangeStatus::Added,
34782                    baseline_code: 0,
34783                    current_code: 100,
34784                    code_delta: 100,
34785                    baseline_comment: 0,
34786                    current_comment: 0,
34787                    comment_delta: 0,
34788                    baseline_blank: 0,
34789                    current_blank: 0,
34790                    blank_delta: 0,
34791                    total_delta: 100,
34792                },
34793                sloc_core::FileDelta {
34794                    relative_path: "removed.rs".to_string(),
34795                    language: Some("Rust".to_string()),
34796                    status: FileChangeStatus::Removed,
34797                    baseline_code: 50,
34798                    current_code: 0,
34799                    code_delta: -50,
34800                    baseline_comment: 0,
34801                    current_comment: 0,
34802                    comment_delta: 0,
34803                    baseline_blank: 0,
34804                    current_blank: 0,
34805                    blank_delta: 0,
34806                    total_delta: -50,
34807                },
34808                sloc_core::FileDelta {
34809                    relative_path: "modified.rs".to_string(),
34810                    language: Some("Rust".to_string()),
34811                    status: FileChangeStatus::Modified,
34812                    baseline_code: 80,
34813                    current_code: 100,
34814                    code_delta: 20,
34815                    baseline_comment: 0,
34816                    current_comment: 0,
34817                    comment_delta: 0,
34818                    baseline_blank: 0,
34819                    current_blank: 0,
34820                    blank_delta: 0,
34821                    total_delta: 20,
34822                },
34823                sloc_core::FileDelta {
34824                    relative_path: "unchanged.rs".to_string(),
34825                    language: Some("Rust".to_string()),
34826                    status: FileChangeStatus::Unchanged,
34827                    baseline_code: 200,
34828                    current_code: 200,
34829                    code_delta: 0,
34830                    baseline_comment: 0,
34831                    current_comment: 0,
34832                    comment_delta: 0,
34833                    baseline_blank: 0,
34834                    current_blank: 0,
34835                    blank_delta: 0,
34836                    total_delta: 0,
34837                },
34838            ],
34839            files_added: 1,
34840            files_removed: 1,
34841            files_modified: 1,
34842            files_unchanged: 1,
34843            files_total: 4,
34844        }
34845    }
34846
34847    #[test]
34848    fn sum_added_counts_added_and_positive_modified() {
34849        let cmp = make_mixed_scan_comparison();
34850        assert_eq!(sum_added_code_lines(&cmp), 120);
34851    }
34852
34853    #[test]
34854    fn sum_removed_counts_removed_baseline() {
34855        let cmp = make_mixed_scan_comparison();
34856        assert_eq!(sum_removed_code_lines(&cmp), 50);
34857    }
34858
34859    #[test]
34860    fn sum_unmodified_counts_unchanged_files() {
34861        let cmp = make_mixed_scan_comparison();
34862        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34863    }
34864
34865    // ── detect_coverage_tool ──────────────────────────────────────────────────
34866
34867    #[test]
34868    fn detect_coverage_tool_rust_project() {
34869        let dir = tempfile::tempdir().unwrap();
34870        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34871        let (tool, cmd) = detect_coverage_tool(dir.path());
34872        assert_eq!(tool, Some("cargo-llvm-cov"));
34873        assert!(cmd.is_some());
34874    }
34875
34876    #[test]
34877    fn detect_coverage_tool_java_gradle() {
34878        let dir = tempfile::tempdir().unwrap();
34879        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34880        let (tool, _) = detect_coverage_tool(dir.path());
34881        assert_eq!(tool, Some("jacoco"));
34882    }
34883
34884    #[test]
34885    fn detect_coverage_tool_python_pyproject() {
34886        let dir = tempfile::tempdir().unwrap();
34887        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34888        let (tool, _) = detect_coverage_tool(dir.path());
34889        assert_eq!(tool, Some("pytest-cov"));
34890    }
34891
34892    #[test]
34893    fn detect_coverage_tool_unknown_project() {
34894        let dir = tempfile::tempdir().unwrap();
34895        let (tool, cmd) = detect_coverage_tool(dir.path());
34896        assert!(tool.is_none() && cmd.is_none());
34897    }
34898
34899    // ── sanitize_path_str / display_path ─────────────────────────────────────
34900
34901    #[test]
34902    fn sanitize_path_str_unc_drive_stripped() {
34903        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34904    }
34905
34906    #[test]
34907    fn sanitize_path_str_unc_network_stripped() {
34908        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34909    }
34910
34911    #[test]
34912    fn sanitize_path_str_plain_path_unchanged() {
34913        assert_eq!(
34914            sanitize_path_str("/home/user/project"),
34915            "/home/user/project"
34916        );
34917    }
34918
34919    #[test]
34920    fn display_path_plain_linux_unchanged() {
34921        assert_eq!(
34922            display_path(Path::new("/home/user/project")),
34923            "/home/user/project"
34924        );
34925    }
34926
34927    #[test]
34928    fn display_path_unc_drive_stripped() {
34929        let result = display_path(Path::new(r"\\?\C:\Users\user"));
34930        assert_eq!(result, r"C:\Users\user");
34931    }
34932
34933    #[test]
34934    fn display_path_unc_network_stripped() {
34935        let result = display_path(Path::new(r"\\?\UNC\server\share"));
34936        assert_eq!(result, r"\\server\share");
34937    }
34938}
34939
34940#[cfg(test)]
34941mod coverage_boost_unit_tests {
34942    use super::*;
34943    use std::path::{Path, PathBuf};
34944
34945    // Both scenarios live in one test (sequential, under a Tokio runtime) because
34946    // load_runtime_security_config spawns a pruning task and mutates process-global
34947    // env vars — parallel sub-tests would race on both.
34948    #[tokio::test]
34949    async fn runtime_security_config_scenarios() {
34950        // FIXME: Audit that the environment access only happens in single-threaded code.
34951        unsafe { std::env::remove_var("SLOC_API_KEYS") };
34952        // FIXME: Audit that the environment access only happens in single-threaded code.
34953        unsafe { std::env::remove_var("SLOC_API_KEY") };
34954        // FIXME: Audit that the environment access only happens in single-threaded code.
34955        unsafe { std::env::remove_var("SLOC_TLS_CERT") };
34956        // FIXME: Audit that the environment access only happens in single-threaded code.
34957        unsafe { std::env::remove_var("SLOC_TLS_KEY") };
34958        // FIXME: Audit that the environment access only happens in single-threaded code.
34959        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34960        // FIXME: Audit that the environment access only happens in single-threaded code.
34961        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34962        let cfg = load_runtime_security_config(false);
34963        assert!(cfg.api_keys.is_empty());
34964        assert!(!cfg.tls_enabled);
34965        assert!(!cfg.trust_proxy);
34966
34967        // FIXME: Audit that the environment access only happens in single-threaded code.
34968        unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
34969        // FIXME: Audit that the environment access only happens in single-threaded code.
34970        unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
34971        // FIXME: Audit that the environment access only happens in single-threaded code.
34972        unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
34973        // FIXME: Audit that the environment access only happens in single-threaded code.
34974        unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
34975        // FIXME: Audit that the environment access only happens in single-threaded code.
34976        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
34977        // FIXME: Audit that the environment access only happens in single-threaded code.
34978        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
34979        let cfg = load_runtime_security_config(true);
34980        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34981        assert!(cfg.trust_proxy);
34982        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34983        // FIXME: Audit that the environment access only happens in single-threaded code.
34984        unsafe { std::env::remove_var("SLOC_API_KEYS") };
34985        // FIXME: Audit that the environment access only happens in single-threaded code.
34986        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34987        // FIXME: Audit that the environment access only happens in single-threaded code.
34988        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34989        // FIXME: Audit that the environment access only happens in single-threaded code.
34990        unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
34991        // FIXME: Audit that the environment access only happens in single-threaded code.
34992        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
34993        // FIXME: Audit that the environment access only happens in single-threaded code.
34994        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
34995    }
34996
34997    #[test]
34998    fn cors_layer_builds_both_modes() {
34999        let _ = build_cors_layer(true);
35000        let _ = build_cors_layer(false);
35001    }
35002
35003    #[test]
35004    fn primary_lan_ip_callable() {
35005        // May be Some or None depending on the host; both are valid.
35006        let _ = primary_lan_ip();
35007    }
35008
35009    #[test]
35010    fn safe_redirect_allows_relative_rejects_absolute() {
35011        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
35012        assert_eq!(safe_redirect("https://evil.example/x"), "/");
35013        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
35014        assert_eq!(default_redirect(), "/view-reports");
35015    }
35016
35017    #[test]
35018    fn tarball_size_caps_env_override() {
35019        // FIXME: Audit that the environment access only happens in single-threaded code.
35020        unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
35021        // FIXME: Audit that the environment access only happens in single-threaded code.
35022        unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
35023        let (c, d) = parse_tarball_size_caps();
35024        assert_eq!(c, 1024 * 1024);
35025        assert_eq!(d, 2 * 1024 * 1024);
35026        // FIXME: Audit that the environment access only happens in single-threaded code.
35027        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
35028        // FIXME: Audit that the environment access only happens in single-threaded code.
35029        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
35030        let (c2, _) = parse_tarball_size_caps();
35031        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
35032    }
35033
35034    #[test]
35035    fn upload_path_helpers() {
35036        let base = upload_base_dir();
35037        let staged = upload_staging_path("abc123");
35038        assert!(staged.starts_with(&base));
35039        assert!(
35040            is_upload_tmp_path(&staged),
35041            "staging path is an upload tmp path"
35042        );
35043        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
35044    }
35045
35046    #[test]
35047    fn git_clones_dir_env_override() {
35048        // FIXME: Audit that the environment access only happens in single-threaded code.
35049        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35050        let def = resolve_git_clones_dir(Path::new("/out"));
35051        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
35052        // FIXME: Audit that the environment access only happens in single-threaded code.
35053        unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
35054        assert_eq!(
35055            resolve_git_clones_dir(Path::new("/out")),
35056            PathBuf::from("/custom/clones")
35057        );
35058        // FIXME: Audit that the environment access only happens in single-threaded code.
35059        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35060    }
35061
35062    #[test]
35063    fn html_report_file_detection() {
35064        let dir = std::env::temp_dir().join("sloc_html_detect");
35065        let _ = std::fs::create_dir_all(&dir);
35066        let good = dir.join("report_x.html");
35067        std::fs::write(&good, "<html></html>").unwrap();
35068        let bad = dir.join("notes.txt");
35069        std::fs::write(&bad, "x").unwrap();
35070        assert!(is_html_report_file(&good));
35071        assert!(!is_html_report_file(&bad));
35072        assert!(find_html_report_in_dir(&dir).is_some());
35073        let _ = std::fs::remove_dir_all(&dir);
35074    }
35075
35076    #[test]
35077    fn multi_delta_class_and_format() {
35078        assert_eq!(multi_delta_class(5), "pos");
35079        assert_eq!(multi_delta_class(-5), "neg");
35080        assert_eq!(multi_delta_class(0), "zero");
35081        assert_eq!(multi_fmt_delta(3), "+3");
35082        assert_eq!(multi_fmt_delta(-3), "-3");
35083        assert_eq!(multi_fmt_delta(0), "0");
35084    }
35085
35086    #[test]
35087    fn git_clone_dest_sanitizes() {
35088        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
35089        assert!(dest.starts_with("/clones"));
35090        let name = dest.file_name().unwrap().to_str().unwrap();
35091        assert!(
35092            name.chars()
35093                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
35094        );
35095    }
35096}
35097
35098#[cfg(test)]
35099mod tests_private {
35100    use super::*;
35101    use std::io::Read;
35102
35103    // ── Server-mode fail-closed auth gate ──────────────────────────────────────
35104
35105    #[test]
35106    fn local_mode_never_refuses_start() {
35107        // Desktop / local mode is open by design regardless of key presence.
35108        assert!(!refuse_unauthenticated_server(false, false));
35109        assert!(!refuse_unauthenticated_server(false, true));
35110    }
35111
35112    #[test]
35113    fn server_mode_with_key_is_allowed() {
35114        assert!(!refuse_unauthenticated_server(true, true));
35115    }
35116
35117    // Env-mutating assertions live in one test so they run sequentially: the
35118    // process-global env var would otherwise race across parallel test threads.
35119    #[test]
35120    fn server_mode_auth_gate_respects_optin() {
35121        // FIXME: Audit that the environment access only happens in single-threaded code.
35122        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35123        assert!(
35124            refuse_unauthenticated_server(true, false),
35125            "server mode + no key must fail closed by default"
35126        );
35127        // FIXME: Audit that the environment access only happens in single-threaded code.
35128        unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
35129        assert!(
35130            !refuse_unauthenticated_server(true, false),
35131            "explicit opt-in must allow the unauthenticated server"
35132        );
35133        // FIXME: Audit that the environment access only happens in single-threaded code.
35134        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35135    }
35136
35137    // ── Health checks & response compression helpers ───────────────────────────
35138
35139    #[test]
35140    fn dir_writable_true_for_temp_dir() {
35141        assert!(dir_writable(&std::env::temp_dir()));
35142    }
35143
35144    #[test]
35145    fn dir_writable_empty_path_is_ok() {
35146        assert!(dir_writable(std::path::Path::new("")));
35147    }
35148
35149    #[test]
35150    fn is_compressible_type_matches_text_and_json() {
35151        assert!(is_compressible_type("text/html; charset=utf-8"));
35152        assert!(is_compressible_type("application/json"));
35153        assert!(is_compressible_type("image/svg+xml"));
35154        assert!(is_compressible_type("application/javascript"));
35155        assert!(!is_compressible_type("application/pdf"));
35156        assert!(!is_compressible_type("application/gzip"));
35157        assert!(!is_compressible_type("image/png"));
35158        assert!(!is_compressible_type(""));
35159    }
35160
35161    #[test]
35162    fn client_accepts_gzip_parses_header() {
35163        let mut h = axum::http::HeaderMap::new();
35164        assert!(!client_accepts_gzip(&h));
35165        h.insert(
35166            header::ACCEPT_ENCODING,
35167            HeaderValue::from_static("br, gzip, deflate"),
35168        );
35169        assert!(client_accepts_gzip(&h));
35170        h.insert(
35171            header::ACCEPT_ENCODING,
35172            HeaderValue::from_static("identity"),
35173        );
35174        assert!(!client_accepts_gzip(&h));
35175    }
35176
35177    #[test]
35178    fn http_timeout_defaults_are_sane() {
35179        // Whatever the ambient env, the timeout is always a positive duration.
35180        assert!(http_timeout() >= std::time::Duration::from_secs(1));
35181    }
35182
35183    #[test]
35184    fn uptime_seconds_is_monotonic_nonpanicking() {
35185        // Anchors the clock and returns a value without panicking.
35186        let _ = uptime_seconds();
35187    }
35188
35189    // ── Zip-slip / path-traversal on tarball extraction ────────────────────────
35190
35191    /// Hand-build a raw USTAR block for `name`/`data`, bypassing `tar::Builder`
35192    /// (which refuses to *write* a `..` path). This lets us feed the *reader* a
35193    /// genuinely malicious archive, which is where the zip-slip guard must hold.
35194    fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
35195        let mut h = [0u8; 512];
35196        let nb = name.as_bytes();
35197        h[..nb.len()].copy_from_slice(nb);
35198        h[100..108].copy_from_slice(b"0000644\0");
35199        h[108..116].copy_from_slice(b"0000000\0");
35200        h[116..124].copy_from_slice(b"0000000\0");
35201        h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
35202        h[136..148].copy_from_slice(b"00000000000\0");
35203        h[156] = b'0'; // typeflag: regular file
35204        h[257..263].copy_from_slice(b"ustar\0");
35205        h[263..265].copy_from_slice(b"00");
35206        for b in &mut h[148..156] {
35207            *b = b' ';
35208        }
35209        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
35210        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
35211
35212        let mut out = h.to_vec();
35213        out.extend_from_slice(data);
35214        out.resize(out.len() + (512 - data.len() % 512) % 512, 0); // pad file to 512
35215        out.resize(out.len() + 1024, 0); // two trailing zero blocks
35216        out
35217    }
35218
35219    /// A malicious tar whose entry path escapes the destination via `..` must not
35220    /// write outside the staging directory. Locks in the `tar::Archive::unpack`
35221    /// zip-slip guard as a regression test.
35222    #[tokio::test]
35223    async fn tarball_extraction_blocks_zip_slip() {
35224        use std::io::Write as _;
35225
35226        let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
35227        let staging = base.join("staging");
35228        let tar_gz = base.join("evil.tar.gz");
35229        std::fs::create_dir_all(&base).unwrap();
35230
35231        // Write a gzip-compressed tar whose single entry is "../escaped.txt".
35232        {
35233            let f = std::fs::File::create(&tar_gz).unwrap();
35234            let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
35235            enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
35236                .unwrap();
35237            enc.finish().unwrap().flush().unwrap();
35238        }
35239
35240        // Extraction must not write the escaped file beside the staging directory.
35241        let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
35242
35243        let escaped = base.join("escaped.txt");
35244        assert!(
35245            !escaped.exists(),
35246            "zip-slip entry escaped staging to {}",
35247            escaped.display()
35248        );
35249
35250        let _ = std::fs::remove_dir_all(&base);
35251    }
35252
35253    #[test]
35254    fn size_limit_reader_zero_remaining_returns_error() {
35255        let data = b"hello world";
35256        let mut reader = SizeLimitReader {
35257            inner: &data[..],
35258            remaining: 0,
35259        };
35260        let mut buf = [0u8; 4];
35261        assert!(reader.read(&mut buf).is_err());
35262    }
35263
35264    #[test]
35265    fn size_limit_reader_counts_bytes() {
35266        let data = b"hello world";
35267        let mut reader = SizeLimitReader {
35268            inner: &data[..],
35269            remaining: 5,
35270        };
35271        let mut buf = [0u8; 4];
35272        let n = reader.read(&mut buf).unwrap();
35273        assert_eq!(n, 4);
35274        assert_eq!(reader.remaining, 1);
35275    }
35276
35277    #[test]
35278    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
35279        let uuid = "12345678-1234-1234-1234-123456789012";
35280        let (id, path) = resolve_or_create_staging(Some(uuid));
35281        assert_eq!(id, uuid);
35282        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
35283    }
35284
35285    #[test]
35286    fn resolve_or_create_staging_with_none_creates_new() {
35287        let (id1, _) = resolve_or_create_staging(None);
35288        let (id2, _) = resolve_or_create_staging(None);
35289        assert_ne!(id1, id2);
35290    }
35291
35292    #[test]
35293    fn resolve_or_create_staging_with_path_separator_creates_new() {
35294        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
35295        let (id, _) = resolve_or_create_staging(Some("has/slash"));
35296        assert_ne!(id, "has/slash");
35297    }
35298
35299    #[test]
35300    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
35301        use std::net::IpAddr;
35302        use std::str::FromStr;
35303        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
35304        let ip = IpAddr::from_str("192.168.1.1").unwrap();
35305        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
35306    }
35307
35308    #[test]
35309    fn is_auth_locked_out_expired_entry_removed() {
35310        use std::net::IpAddr;
35311        use std::str::FromStr;
35312        let limiter = IpRateLimiter::new(
35313            Duration::from_mins(1),
35314            100,
35315            1, // 1 failure triggers lockout
35316            Duration::from_millis(1),
35317        );
35318        let ip = IpAddr::from_str("192.168.1.2").unwrap();
35319        limiter.record_auth_failure(ip);
35320        // Wait for the 1ms window to expire
35321        std::thread::sleep(Duration::from_millis(10));
35322        // Expired entry should be removed, returning false
35323        assert!(!limiter.is_auth_locked_out(ip));
35324    }
35325
35326    #[test]
35327    fn is_auth_locked_out_within_window_returns_true() {
35328        use std::net::IpAddr;
35329        use std::str::FromStr;
35330        let limiter = IpRateLimiter::new(
35331            Duration::from_mins(1),
35332            100,
35333            2, // 2 failures triggers lockout
35334            Duration::from_hours(1),
35335        );
35336        let ip = IpAddr::from_str("192.168.1.3").unwrap();
35337        limiter.record_auth_failure(ip);
35338        limiter.record_auth_failure(ip);
35339        assert!(limiter.is_auth_locked_out(ip));
35340    }
35341
35342    // ── output_folder_hint ───────────────────────────────────────────────────────
35343
35344    #[test]
35345    fn output_folder_hint_strips_json_subdir() {
35346        use std::path::Path;
35347        let path = Path::new("/output/scan1/json/result.json");
35348        let hint = output_folder_hint(path);
35349        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35350    }
35351
35352    #[test]
35353    fn output_folder_hint_strips_html_subdir() {
35354        use std::path::Path;
35355        let path = Path::new("/output/scan1/html/report.html");
35356        let hint = output_folder_hint(path);
35357        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35358    }
35359
35360    #[test]
35361    fn output_folder_hint_strips_pdf_subdir() {
35362        use std::path::Path;
35363        let path = Path::new("/output/scan1/pdf/report.pdf");
35364        let hint = output_folder_hint(path);
35365        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35366    }
35367
35368    #[test]
35369    fn output_folder_hint_strips_excel_subdir() {
35370        use std::path::Path;
35371        let path = Path::new("/output/scan1/excel/report.xlsx");
35372        let hint = output_folder_hint(path);
35373        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35374    }
35375
35376    #[test]
35377    fn output_folder_hint_flat_layout_returns_direct_parent() {
35378        use std::path::Path;
35379        let path = Path::new("/output/scan1/result.json");
35380        let hint = output_folder_hint(path);
35381        assert!(
35382            hint.ends_with("scan1"),
35383            "expected direct parent, got: {hint}"
35384        );
35385    }
35386
35387    #[test]
35388    fn output_folder_hint_other_subdir_name_not_stripped() {
35389        use std::path::Path;
35390        // "data" is not one of the named artifact subdirs — parent is kept as-is
35391        let path = Path::new("/output/scan1/data/result.json");
35392        let hint = output_folder_hint(path);
35393        assert!(
35394            hint.ends_with("data"),
35395            "non-artifact subdir must not be stripped, got: {hint}"
35396        );
35397    }
35398
35399    // ── find_file_by_ext ─────────────────────────────────────────────────────────
35400
35401    #[test]
35402    fn find_file_by_ext_finds_matching_file() {
35403        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35404        let _ = fs::create_dir_all(&dir);
35405        let f = dir.join("report.pdf");
35406        let _ = fs::write(&f, b"dummy");
35407        let result = find_file_by_ext(&dir, "pdf");
35408        assert!(result.is_some(), "expected to find report.pdf");
35409        let _ = fs::remove_dir_all(&dir);
35410    }
35411
35412    #[test]
35413    fn find_file_by_ext_returns_none_for_missing_ext() {
35414        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35415        let _ = fs::create_dir_all(&dir);
35416        let f = dir.join("report.json");
35417        let _ = fs::write(&f, b"{}");
35418        let result = find_file_by_ext(&dir, "pdf");
35419        assert!(result.is_none());
35420        let _ = fs::remove_dir_all(&dir);
35421    }
35422
35423    #[test]
35424    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35425        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35426        assert!(find_file_by_ext(dir, "json").is_none());
35427    }
35428
35429    // ── collect_result_json_candidates ───────────────────────────────────────────
35430
35431    #[test]
35432    fn collect_result_json_candidates_flat_root() {
35433        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35434        let _ = fs::create_dir_all(&root);
35435        let _ = fs::write(root.join("result.json"), b"{}");
35436        let candidates = collect_result_json_candidates(&root);
35437        assert!(!candidates.is_empty(), "should find result.json at root");
35438        let _ = fs::remove_dir_all(&root);
35439    }
35440
35441    #[test]
35442    fn collect_result_json_candidates_legacy_subdir() {
35443        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35444        let sub = root.join("scanA");
35445        let _ = fs::create_dir_all(&sub);
35446        let _ = fs::write(sub.join("result.json"), b"{}");
35447        let candidates = collect_result_json_candidates(&root);
35448        assert!(
35449            !candidates.is_empty(),
35450            "should find result.json in legacy subdir"
35451        );
35452        let _ = fs::remove_dir_all(&root);
35453    }
35454
35455    #[test]
35456    fn collect_result_json_candidates_structured_json_subdir() {
35457        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35458        let json_sub = root.join("scanB").join("json");
35459        let _ = fs::create_dir_all(&json_sub);
35460        let _ = fs::write(json_sub.join("result.json"), b"{}");
35461        let candidates = collect_result_json_candidates(&root);
35462        assert!(
35463            !candidates.is_empty(),
35464            "should find result.json inside <subdir>/json/"
35465        );
35466        let _ = fs::remove_dir_all(&root);
35467    }
35468
35469    #[test]
35470    fn collect_result_json_candidates_empty_dir() {
35471        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35472        let _ = fs::create_dir_all(&root);
35473        let candidates = collect_result_json_candidates(&root);
35474        assert!(candidates.is_empty());
35475        let _ = fs::remove_dir_all(&root);
35476    }
35477}