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::{verify_audit_file, AuditVerifyReport};
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    body::Body,
49    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
50    http::{header, HeaderValue, Request, StatusCode},
51    middleware::{self, Next},
52    response::{Html, IntoResponse, Response},
53    routing::{get, post},
54    Json, Router,
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    analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
74    CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
75    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
76};
77use sloc_report::{
78    render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
79    write_pdf_from_run, ReportDeltaContext,
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    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    extern "system" {
153        fn GetCurrentThreadId() -> DWORD;
154    }
155
156    #[link(name = "shell32")]
157    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        let mut existing = std::collections::HashSet::new();
206        let mut prev: HWND = core::ptr::null_mut();
207        loop {
208            let w = FindWindowExW(
209                core::ptr::null_mut(),
210                prev,
211                class_w.as_ptr(),
212                core::ptr::null(),
213            );
214            if w.is_null() {
215                break;
216            }
217            existing.insert(w as usize);
218            prev = w;
219        }
220        existing
221    }
222
223    unsafe fn find_new_explorer_hwnd(
224        class_w: &[u16],
225        existing: &std::collections::HashSet<usize>,
226    ) -> Option<HWND> {
227        let mut prev: HWND = core::ptr::null_mut();
228        loop {
229            let w = FindWindowExW(
230                core::ptr::null_mut(),
231                prev,
232                class_w.as_ptr(),
233                core::ptr::null(),
234            );
235            if w.is_null() {
236                return None;
237            }
238            if !existing.contains(&(w as usize)) {
239                return Some(w);
240            }
241            prev = w;
242        }
243    }
244
245    unsafe fn bring_to_front(hwnd: HWND) {
246        // Surfacing a window owned by another process (Explorer) from a
247        // background thread is blocked by Windows' foreground lock:
248        // SetForegroundWindow silently fails and only the taskbar button
249        // flashes.  The reliable workaround is to temporarily attach our input
250        // queue to the thread that currently owns the foreground window — while
251        // attached, SetForegroundWindow/BringWindowToTop actually activate the
252        // window instead of merely flashing it.
253        let my_tid = GetCurrentThreadId();
254        let fg_hwnd = GetForegroundWindow();
255        let fg_tid = if fg_hwnd.is_null() {
256            0
257        } else {
258            GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
259        };
260        let attached = fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
261
262        // SW_RESTORE = 9 — un-minimise the Explorer window (it may have opened
263        // as a taskbar button) without forcing a full-screen maximise.
264        ShowWindow(hwnd, 9);
265        BringWindowToTop(hwnd);
266        SetForegroundWindow(hwnd);
267        // Extra belt-and-braces activation that also bypasses the foreground
268        // lock on older Windows builds.
269        SwitchToThisWindow(hwnd, 1);
270
271        // Force the Z-order to the very top regardless of the foreground-lock
272        // outcome by flipping TOPMOST on then off, so the window jumps above all
273        // others without staying pinned. HWND_TOPMOST = -1, HWND_NOTOPMOST = -2;
274        // SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE = 0x0013.
275        SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
276        SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
277
278        if attached {
279            AttachThreadInput(my_tid, fg_tid, 0);
280        }
281    }
282
283    /// Opens `path` in Windows Explorer and forces it to the foreground.
284    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
285    /// caller is not the foreground process (the browser is).  After launching,
286    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
287    /// an undocumented API that bypasses Windows' foreground-lock restriction
288    /// so the window surfaces regardless of which process currently has focus.
289    pub fn open_folder_foreground(path: std::path::PathBuf) {
290        std::thread::spawn(move || {
291            use std::os::windows::ffi::OsStrExt;
292
293            let op: Vec<u16> = "explore\0".encode_utf16().collect();
294            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
295            path_w.push(0);
296            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
297
298            unsafe {
299                // Snapshot every existing Explorer window before we launch so
300                // we can identify the newly created one.
301                let existing = snapshot_explorer_hwnds(&class_w);
302                let fg_hwnd = GetForegroundWindow();
303                // SW_SHOWNORMAL = 1
304                ShellExecuteW(
305                    fg_hwnd,
306                    op.as_ptr(),
307                    path_w.as_ptr(),
308                    core::ptr::null(),
309                    core::ptr::null(),
310                    1,
311                );
312
313                // Poll up to ~3 s for a new CabinetWClass window to appear,
314                // then use SwitchToThisWindow (bypasses foreground-lock) to
315                // bring it in front of the browser and everything else.
316                for _ in 0..40 {
317                    std::thread::sleep(std::time::Duration::from_millis(75));
318                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
319                        bring_to_front(w);
320                        return;
321                    }
322                }
323
324                // Fallback: Explorer reused an existing window — bring whichever
325                // CabinetWClass window is first in Z-order to the front.
326                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
327                if !w.is_null() {
328                    bring_to_front(w);
329                }
330            }
331        });
332    }
333
334    /// Spawns a short-lived watcher thread that polls for a dialog window
335    /// matching `title` and, once found, forces it to the foreground and
336    /// flashes its taskbar button until the user interacts with it.
337    #[cfg(feature = "native-dialog")]
338    pub fn flash_dialog_when_ready(title: String) {
339        std::thread::spawn(move || {
340            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
341            for _ in 0..40 {
342                std::thread::sleep(std::time::Duration::from_millis(80));
343                unsafe {
344                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
345                    if !hwnd.is_null() {
346                        SetForegroundWindow(hwnd);
347                        BringWindowToTop(hwnd);
348                        #[allow(non_snake_case)]
349                        FlashWindowEx(&FLASHWINFO {
350                            // size_of returns usize; Win32 struct field is u32 (UINT).
351                            // struct size fits trivially within u32.
352                            #[allow(clippy::cast_possible_truncation)]
353                            cbSize: size_of::<FLASHWINFO>() as UINT,
354                            hwnd,
355                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
356                            uCount: 3,
357                            dwTimeout: 0,
358                        });
359                        break;
360                    }
361                }
362            }
363        });
364    }
365}
366
367/// Sliding-window rate limiter keyed by client IP.
368/// Uses only std primitives — no external crate required.
369pub(crate) struct IpRateLimiter {
370    window: Duration,
371    max_requests: usize,
372    pub(crate) auth_lockout_threshold: u32,
373    auth_lockout_window: Duration,
374    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
375    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
376}
377
378impl IpRateLimiter {
379    pub(crate) fn new(
380        window: Duration,
381        max_requests: usize,
382        auth_lockout_threshold: u32,
383        auth_lockout_window: Duration,
384    ) -> Self {
385        Self {
386            window,
387            max_requests,
388            auth_lockout_threshold,
389            auth_lockout_window,
390            state: std::sync::Mutex::new(HashMap::new()),
391            auth_failures: std::sync::Mutex::new(HashMap::new()),
392        }
393    }
394
395    // The MutexGuard `state` must live as long as `bucket` borrows from it,
396    // so it cannot be dropped any earlier than the end of the inner block.
397    #[allow(clippy::significant_drop_tightening)]
398    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
399        let now = Instant::now();
400        let cutoff = now.checked_sub(self.window).unwrap_or(now);
401        let mut state = self
402            .state
403            .lock()
404            .unwrap_or_else(std::sync::PoisonError::into_inner);
405        if state.len() > 10_000 {
406            state.retain(|_, bucket| {
407                while bucket.front().is_some_and(|t| *t <= cutoff) {
408                    bucket.pop_front();
409                }
410                !bucket.is_empty()
411            });
412        }
413        let bucket = state.entry(ip).or_default();
414        while bucket.front().is_some_and(|t| *t <= cutoff) {
415            bucket.pop_front();
416        }
417        if bucket.len() >= self.max_requests {
418            false
419        } else {
420            bucket.push_back(now);
421            true
422        }
423    }
424
425    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
426        let now = Instant::now();
427        let mut map = self
428            .auth_failures
429            .lock()
430            .unwrap_or_else(std::sync::PoisonError::into_inner);
431        map.entry(ip)
432            .and_modify(|e| {
433                e.0 += 1;
434                e.1 = now;
435            })
436            .or_insert_with(|| (1, now));
437    }
438
439    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
440        let mut map = self
441            .auth_failures
442            .lock()
443            .unwrap_or_else(std::sync::PoisonError::into_inner);
444        let expired = map
445            .get(&ip)
446            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
447        if expired {
448            map.remove(&ip);
449            return false;
450        }
451        map.get(&ip)
452            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
453    }
454
455    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
456        let map = self
457            .auth_failures
458            .lock()
459            .unwrap_or_else(std::sync::PoisonError::into_inner);
460        map.get(&ip).map_or(0, |e| {
461            self.auth_lockout_window
462                .checked_sub(e.1.elapsed())
463                .map_or(0, |r| r.as_secs())
464        })
465    }
466
467    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
468        tokio::spawn(async move {
469            let mut interval = tokio::time::interval(Duration::from_mins(1));
470            interval.tick().await; // consume the immediate first tick
471            loop {
472                interval.tick().await;
473                let now = Instant::now();
474                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
475                {
476                    let mut state = limiter
477                        .state
478                        .lock()
479                        .unwrap_or_else(std::sync::PoisonError::into_inner);
480                    state.retain(|_, bucket| {
481                        while bucket.front().is_some_and(|t| *t <= cutoff) {
482                            bucket.pop_front();
483                        }
484                        !bucket.is_empty()
485                    });
486                }
487                {
488                    let mut auth = limiter
489                        .auth_failures
490                        .lock()
491                        .unwrap_or_else(std::sync::PoisonError::into_inner);
492                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
493                }
494            }
495        });
496    }
497}
498
499/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
500/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
501/// files but never triggers a scan.
502fn spawn_upload_staging_cleanup() {
503    tokio::spawn(async move {
504        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
505            .ok()
506            .and_then(|v| v.parse().ok())
507            .unwrap_or(4);
508        let ttl_secs = ttl_hours * 3600;
509        let mut interval = tokio::time::interval(Duration::from_hours(1));
510        interval.tick().await; // consume the immediate first tick
511        loop {
512            interval.tick().await;
513            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
514            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
515                continue;
516            };
517            while let Ok(Some(entry)) = dir.next_entry().await {
518                let path = entry.path();
519                let age_secs = tokio::fs::metadata(&path)
520                    .await
521                    .ok()
522                    .and_then(|m| m.modified().ok())
523                    .and_then(|t| t.elapsed().ok())
524                    .map_or(0, |d| d.as_secs());
525                if age_secs > ttl_secs {
526                    tracing::debug!(
527                        event = "upload_staging_cleanup",
528                        path = %path.display(),
529                        age_secs,
530                        "removing stale upload staging directory"
531                    );
532                    let _ = tokio::fs::remove_dir_all(&path).await;
533                }
534            }
535        }
536    });
537}
538
539/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
540#[derive(Clone, Debug, Default)]
541struct RunResultContext {
542    prev_entry: Option<RegistryEntry>,
543    prev_scan_count: usize,
544    project_path: String,
545    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
546    cocomo_mode: String,
547    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
548    complexity_alert: u32,
549    /// Whether duplicate files should be excluded from displayed SLOC totals.
550    #[allow(dead_code)]
551    exclude_duplicates: bool,
552}
553
554/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
555#[derive(Clone)]
556enum AsyncRunState {
557    Running {
558        started_at: std::time::Instant,
559        cancel_token: Arc<std::sync::atomic::AtomicBool>,
560        phase: Arc<std::sync::Mutex<String>>,
561        files_done: Arc<std::sync::atomic::AtomicUsize>,
562        files_total: Arc<std::sync::atomic::AtomicUsize>,
563    },
564    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
565    Complete {
566        run_id: String,
567    },
568    Failed {
569        message: String,
570    },
571    Cancelled,
572}
573
574/// A saved scan configuration profile — stores the form parameters so users can
575/// re-run a favourite scan with one click.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577struct ScanProfile {
578    id: String,
579    name: String,
580    created_at: String,
581    /// The raw scan-form parameters serialized as JSON.
582    params: serde_json::Value,
583}
584
585#[derive(Debug, Clone, Default, Serialize, Deserialize)]
586struct ScanProfileStore {
587    profiles: Vec<ScanProfile>,
588}
589
590impl ScanProfileStore {
591    fn load(path: &std::path::Path) -> Self {
592        fs::read_to_string(path)
593            .ok()
594            .and_then(|s| serde_json::from_str(&s).ok())
595            .unwrap_or_default()
596    }
597
598    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
599        if let Some(parent) = path.parent() {
600            fs::create_dir_all(parent)?;
601        }
602        let json = serde_json::to_string_pretty(self)?;
603        fs::write(path, json)?;
604        Ok(())
605    }
606}
607
608/// Server-side session record. `absolute_expiry` is the hard 8-hour cap (unchanged);
609/// `last_seen` supports the optional sliding idle timeout (see `session_idle_timeout`).
610#[derive(Clone, Copy)]
611pub(crate) struct SessionState {
612    pub(crate) absolute_expiry: Instant,
613    pub(crate) last_seen: Instant,
614}
615
616// The bool fields below are independent runtime flags (server mode, unauth-allow,
617// TLS, proxy trust), not a state machine. Folding them into an enum/sub-struct would
618// churn every construction and access site across this crate for no clarity gain —
619// and that mechanical churn is exactly what risks the new_duplicated_lines_density
620// gate. Scope the allow to this struct rather than refactoring.
621#[allow(clippy::struct_excessive_bools)]
622#[derive(Clone)]
623pub(crate) struct AppState {
624    pub(crate) base_config: AppConfig,
625    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
626    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
627    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
628    pub(crate) registry_path: PathBuf,
629    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
630    pub(crate) server_mode: bool,
631    /// Operator explicitly accepted running server mode with no API key
632    /// (`SLOC_ALLOW_UNAUTHENTICATED=1`). When false, an unauthenticated server-mode
633    /// request fails closed with 503 instead of being served open.
634    pub(crate) allow_unauthenticated: bool,
635    pub(crate) tls_enabled: bool,
636    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
637    /// Read-only credentials (`SLOC_API_KEYS_READONLY`): authenticate for safe
638    /// (GET/HEAD/OPTIONS) requests but are rejected on state-changing methods.
639    /// Empty by default, so all keys are full-access — the prior behaviour.
640    pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
641    pub(crate) rate_limiter: Arc<IpRateLimiter>,
642    pub(crate) trust_proxy: bool,
643    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
644    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
645    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
646    /// Directory where remote repositories are cloned for git-browser scans.
647    pub(crate) git_clones_dir: PathBuf,
648    /// Persisted list of webhook / poll schedules.
649    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
650    pub(crate) schedules_path: PathBuf,
651    /// Named scan profiles saved by the user via the web UI.
652    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
653    pub(crate) scan_profiles_path: PathBuf,
654    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
655    /// Persisted Confluence integration settings.
656    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
657    pub(crate) confluence_path: PathBuf,
658    /// Directories the user has pinned for auto-scanning of external reports.
659    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
660    pub(crate) watched_dirs_path: PathBuf,
661    /// Persisted auto-cleanup policy (age/count limits + interval).
662    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
663    pub(crate) cleanup_policy_path: PathBuf,
664    /// Handle for the running cleanup background task; replaced on policy change.
665    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
666}
667
668type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
669
670/// Parameters for the fire-and-forget HTML + PDF background task.
671
672#[derive(Clone, Debug)]
673pub(crate) struct RunArtifacts {
674    output_dir: PathBuf,
675    html_path: Option<PathBuf>,
676    pdf_path: Option<PathBuf>,
677    json_path: Option<PathBuf>,
678    csv_path: Option<PathBuf>,
679    xlsx_path: Option<PathBuf>,
680    scan_config_path: Option<PathBuf>,
681    report_title: String,
682    result_context: RunResultContext,
683}
684
685#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
686fn build_router(state: AppState) -> Router {
687    let protected = Router::new()
688        .route("/", get(splash))
689        .route("/scan-setup", get(scan_setup_handler))
690        .route("/scan", get(index))
691        .route("/analyze", post(analyze_handler))
692        .route("/preview", get(preview_handler))
693        .route("/api/suggest-coverage", get(api_suggest_coverage))
694        .route("/pick-directory", get(pick_directory_handler))
695        .route("/open-path", get(open_path_handler))
696        .route("/pick-file", get(pick_file_handler))
697        .route(
698            "/api/upload-directory",
699            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
700        )
701        .route(
702            "/api/upload-file",
703            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
704        )
705        .route(
706            "/api/upload-tarball",
707            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
708            // The handler also enforces this limit during streaming so both layers agree.
709            post(upload_tarball_handler)
710                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
711        )
712        .route("/locate-report", post(locate_report_handler))
713        .route("/locate-reports-dir", post(locate_reports_dir_handler))
714        .route("/relocate-scan", post(relocate_scan_handler))
715        .route("/watched-dirs/add", post(add_watched_dir_handler))
716        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
717        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
718        .route("/view-reports", get(history_handler))
719        .route("/compare-scans", get(compare_select_handler))
720        .route("/compare", get(compare_handler))
721        .route("/multi-compare", get(multi_compare_handler))
722        .route("/images/{folder}/{file}", get(image_handler))
723        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
724        .route("/api/metrics/latest", get(api_metrics_latest_handler))
725        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
726        .route("/api/metrics/history", get(api_metrics_history_handler))
727        .route("/api/metrics/churn", get(api_metrics_churn_handler))
728        .route(
729            "/api/metrics/submodules",
730            get(api_metrics_submodules_handler),
731        )
732        .route("/api/ingest", post(api_ingest_handler))
733        .route("/api/project-history", get(project_history_handler))
734        .route("/trend-reports", get(trend_report_handler))
735        .route("/test-metrics", get(test_metrics_handler))
736        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
737        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
738        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
739        .route("/runs/result/{run_id}", get(async_run_result_handler))
740        .route("/embed/summary", get(embed_handler))
741        // ── Git browser ────────────────────────────────────────────────────────
742        .route("/git-browser", get(git_browser::git_browser_handler))
743        .route("/api/git/refs", get(git_browser::api_list_refs))
744        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
745        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
746        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
747        // The request body is the full rendered HTML report, whose size scales
748        // with file count — large repos (Compare Scans, Files, Trend, Test
749        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
750        .route(
751            "/export/pdf",
752            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
753        )
754        // ── Config export / import ─────────────────────────────────────────────
755        .route("/export-config", get(export_config_handler))
756        .route("/import-config", post(import_config_handler))
757        // ── Scan profiles ──────────────────────────────────────────────────────
758        .route("/api/scan-profiles", get(api_list_scan_profiles))
759        .route("/api/scan-profiles", post(api_save_scan_profile))
760        .route(
761            "/api/scan-profiles/{id}",
762            axum::routing::delete(api_delete_scan_profile),
763        )
764        // ── Integrations (webhooks + Confluence) ──────────────────────────────
765        .route("/integrations", get(integrations::integrations_handler))
766        .route(
767            "/webhook-setup",
768            get(|| async { axum::response::Redirect::permanent("/integrations") }),
769        )
770        .route(
771            "/confluence-setup",
772            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
773        )
774        .route("/api/schedules", get(git_webhook::api_list_schedules))
775        .route("/api/schedules", post(git_webhook::api_create_schedule))
776        .route(
777            "/api/schedules",
778            axum::routing::delete(git_webhook::api_delete_schedule),
779        )
780        .route(
781            "/api/confluence/config",
782            get(confluence::api_get_confluence_config),
783        )
784        .route(
785            "/api/confluence/config",
786            post(confluence::api_save_confluence_config),
787        )
788        .route(
789            "/api/confluence/test",
790            post(confluence::api_test_confluence),
791        )
792        .route(
793            "/api/confluence/post",
794            post(confluence::api_post_to_confluence),
795        )
796        .route(
797            "/api/confluence/wiki-markup",
798            get(confluence::api_wiki_markup),
799        )
800        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
801        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
802        .route(
803            "/api/runs/{run_id}",
804            axum::routing::delete(delete_run_handler),
805        )
806        .route("/api/runs/cleanup", post(cleanup_runs_handler))
807        // ── Auto-cleanup policy ────────────────────────────────────────────────
808        .route(
809            "/api/cleanup-policy",
810            get(api_get_cleanup_policy)
811                .post(api_save_cleanup_policy)
812                .delete(api_delete_cleanup_policy),
813        )
814        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
815        // ── REST API reference page ────────────────────────────────────────────
816        .route("/api-docs", get(api_docs_handler))
817        // ── Prometheus metrics — behind API-key auth ───────────────────────────
818        .route("/metrics", get(metrics_handler))
819        .route_layer(middleware::from_fn_with_state(
820            state.clone(),
821            auth::require_api_key,
822        ));
823
824    protected
825        .route("/healthz", get(healthz))
826        .route("/api/health", get(healthz))
827        .route("/api/version", get(api_version_handler))
828        .route("/api/openapi.yaml", get(openapi_yaml_handler))
829        .route("/llms.txt", get(llms_txt_handler))
830        .route("/llms-full.txt", get(llms_full_txt_handler))
831        .route("/badge/{metric}", get(badge_handler))
832        .route("/static/chart.js", get(chart_js_handler))
833        .route("/static/chart-report.js", get(report_chart_js_handler))
834        .route("/auth/login", get(auth::auth_login_get))
835        .route("/auth/login", post(auth::auth_login_post))
836        .route("/auth/logout", post(auth::auth_logout))
837        // Pre-access consent acknowledgement endpoint (public; exempt from the gate).
838        .route("/auth/consent", get(auth::auth_consent_accept))
839        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
840        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
841        .route(
842            "/webhooks/github",
843            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
844        )
845        .route(
846            "/webhooks/gitlab",
847            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
848        )
849        .route(
850            "/webhooks/bitbucket",
851            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
852        )
853        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
854        .layer(middleware::from_fn(consent_gate))
855        .layer(middleware::from_fn(csrf_protect))
856        .layer(middleware::from_fn_with_state(
857            state.clone(),
858            add_security_headers,
859        ))
860        .layer(build_cors_layer(state.server_mode))
861        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
862        .with_state(state)
863}
864
865/// Bearer token used by `make_test_router_server_mode()` test routers.
866/// Tests that exercise server-mode paths must include this key in their requests.
867pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
868
869/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
870/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
871/// builders below start from this and override only the fields they care about.
872///
873/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
874fn test_app_state(tmp_subdir: &str) -> AppState {
875    // Root every router in its OWN temp subdirectory. Multiple routers share a
876    // namespace prefix (e.g. "sloc_test"), so a fixed name would make parallel
877    // tests read/write the same registry.json + artifact tree and race — a
878    // concurrently-mutated shared store is what made multi_compare_* flaky.
879    // A per-call counter (plus PID, to avoid leftover-dir collisions across
880    // runs) guarantees isolation, honouring this fn's "per-test subdir" contract.
881    static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
882    std::env::set_var("SLOC_HEADLESS", "1");
883    let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
884    let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
885    AppState {
886        base_config: AppConfig::default(),
887        artifacts: Arc::new(Mutex::new(HashMap::new())),
888        async_runs: Arc::new(Mutex::new(HashMap::new())),
889        registry: Arc::new(Mutex::new(ScanRegistry::default())),
890        registry_path: tmp.join("registry.json"),
891        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
892        server_mode: false,
893        allow_unauthenticated: false,
894        tls_enabled: false,
895        api_keys: Arc::new(vec![]),
896        readonly_api_keys: Arc::new(vec![]),
897        rate_limiter: Arc::new(IpRateLimiter::new(
898            Duration::from_mins(1),
899            600,
900            10,
901            Duration::from_hours(1),
902        )),
903        trust_proxy: false,
904        trusted_proxy_ips: vec![],
905        git_clones_dir: tmp.join("git-clones"),
906        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
907        schedules_path: tmp.join("schedules.json"),
908        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
909        scan_profiles_path: tmp.join("scan_profiles.json"),
910        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
911        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
912        confluence_path: tmp.join("confluence_config.json"),
913        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
914        watched_dirs_path: tmp.join("watched_dirs.json"),
915        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
916        cleanup_policy_path: tmp.join("cleanup_policy.json"),
917        cleanup_task_handle: Arc::new(Mutex::new(None)),
918    }
919}
920
921/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
922pub fn make_test_router() -> Router {
923    build_router(test_app_state("sloc_test"))
924}
925
926/// Test router with one API key pre-loaded. Used by auth integration tests.
927pub fn make_test_router_with_key(api_key: &str) -> Router {
928    let mut state = test_app_state("sloc_test_key");
929    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
930    build_router(state)
931}
932
933/// Test router with a full-access key AND a read-only key.
934///
935/// Exercises the read-only credential branch in the auth middleware: a read-only
936/// key authenticates safe (GET/HEAD/OPTIONS) requests but is rejected with 403 on
937/// state-changing methods.
938pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
939    let mut state = test_app_state("sloc_test_readonly");
940    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
941    state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
942        readonly_key.to_owned(),
943    ))]);
944    build_router(state)
945}
946
947/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
948/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
949/// preview restrictions.
950pub fn make_test_router_server_mode() -> Router {
951    let mut state = test_app_state("sloc_test_server");
952    state.server_mode = true;
953    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
954        TEST_SERVER_MODE_API_KEY.to_owned(),
955    ))]);
956    build_router(state)
957}
958
959/// Server-mode test router with `allowed_scan_roots` configured.
960///
961/// Exercises the `validate_server_scan_path` allow/deny branches (in-root
962/// success, unresolved path, and out-of-root rejection) that the empty-roots
963/// router cannot reach.
964pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
965    let mut state = test_app_state("sloc_test_server_roots");
966    state.server_mode = true;
967    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
968        TEST_SERVER_MODE_API_KEY.to_owned(),
969    ))]);
970    state.base_config.discovery.allowed_scan_roots = roots;
971    build_router(state)
972}
973
974/// Test router where the analysis semaphore is pre-exhausted (0 permits).
975/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
976pub fn make_test_router_exhausted_semaphore() -> Router {
977    let mut state = test_app_state("sloc_test_exhaust");
978    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
979    build_router(state)
980}
981
982/// Test router with a very tight rate limit (3 req/min). The third request from
983/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
984pub fn make_test_router_tight_rate_limit() -> Router {
985    let mut state = test_app_state("sloc_test_rate");
986    state.rate_limiter = Arc::new(IpRateLimiter::new(
987        Duration::from_mins(1),
988        2,
989        5,
990        Duration::from_secs(5),
991    ));
992    build_router(state)
993}
994
995/// Test router with a very tight auth lockout (threshold=2, window=200ms).
996/// Used by tests that need to trigger and verify the auth lockout response.
997pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
998    let mut state = test_app_state("sloc_test_auth_lockout");
999    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1000    state.rate_limiter = Arc::new(IpRateLimiter::new(
1001        Duration::from_mins(1),
1002        600,
1003        2,                          // 2 failures triggers lockout
1004        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
1005    ));
1006    build_router(state)
1007}
1008
1009struct RuntimeSecurityConfig {
1010    api_keys: Vec<secrecy::SecretBox<String>>,
1011    readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1012    tls_cert: Option<String>,
1013    tls_key: Option<String>,
1014    tls_enabled: bool,
1015    trust_proxy: bool,
1016    trusted_proxy_ips: Vec<IpAddr>,
1017    rate_limiter: Arc<IpRateLimiter>,
1018}
1019
1020/// Whether the operator has explicitly opted into running server mode with no API key.
1021/// This is the single escape hatch for the fail-closed server-mode auth requirement.
1022fn allow_unauthenticated_server_mode() -> bool {
1023    matches!(
1024        std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1025        Ok("1" | "true" | "TRUE")
1026    )
1027}
1028
1029/// Fail-closed startup gate: refuse to launch a network-facing server that has no
1030/// authentication configured, unless the operator explicitly accepted the risk.
1031/// Desktop/local mode (`server_mode == false`) is always allowed.
1032fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1033    server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1034}
1035
1036/// Umbrella strict-posture switch (`SLOC_HARDENED=1`). When set, opt-in hardening
1037/// defaults take effect: transport encryption is required on non-loopback binds and
1038/// the auth-lockout threshold tightens. Off by default so existing deployments are
1039/// unaffected; individual controls also keep their own env overrides.
1040fn hardened_mode() -> bool {
1041    std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1042}
1043
1044/// Whether a certificate must be present before serving a network-facing
1045/// (non-loopback) bind. Opt-in via `SLOC_REQUIRE_TLS=1` or `SLOC_HARDENED=1`. Off by
1046/// default, so cleartext and reverse-proxy-terminated deployments keep working.
1047fn require_tls() -> bool {
1048    hardened_mode()
1049        || std::env::var("SLOC_REQUIRE_TLS")
1050            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1051}
1052
1053/// Optional sliding idle timeout for authenticated sessions. `None` (the default)
1054/// means only the 8-hour absolute cap applies — identical to prior behaviour.
1055/// `SLOC_SESSION_IDLE_SECS=<n>` sets an explicit idle limit (`0` disables); under
1056/// `SLOC_HARDENED` it defaults to 15 minutes. Each authenticated request refreshes
1057/// the session's last-seen time, so the window slides.
1058pub(crate) fn session_idle_timeout() -> Option<Duration> {
1059    match std::env::var("SLOC_SESSION_IDLE_SECS")
1060        .ok()
1061        .and_then(|v| v.parse::<u64>().ok())
1062    {
1063        Some(0) => None,
1064        Some(secs) => Some(Duration::from_secs(secs)),
1065        None if hardened_mode() => Some(Duration::from_mins(15)),
1066        None => None,
1067    }
1068}
1069
1070/// Generic authorized-use notice shown when a banner is required but the operator
1071/// has not supplied custom text via `SLOC_CONSENT_BANNER`.
1072const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1073Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1074are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1075
1076/// The pre-access consent banner text, if enabled. `SLOC_CONSENT_BANNER=<text>`
1077/// sets custom wording; `SLOC_HARDENED` alone falls back to a generic notice.
1078/// `None` (the default) disables the banner entirely.
1079fn consent_banner_text() -> Option<String> {
1080    if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1081        let t = t.trim();
1082        if !t.is_empty() {
1083            return Some(t.to_owned());
1084        }
1085    }
1086    hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1087}
1088
1089/// True when this request is a top-level browser navigation that the consent gate
1090/// should intercept. APIs, assets, webhooks, health checks, and the accept
1091/// endpoint itself are never gated.
1092fn consent_gate_applies(req: &Request<Body>) -> bool {
1093    const EXEMPT: &[&str] = &[
1094        "/auth/consent",
1095        "/static/",
1096        "/images/",
1097        "/assets/",
1098        "/badge/",
1099        "/healthz",
1100        "/api/",
1101        "/webhooks/",
1102        "/metrics",
1103        "/favicon",
1104        "/llms",
1105    ];
1106    if !matches!(
1107        *req.method(),
1108        axum::http::Method::GET | axum::http::Method::HEAD
1109    ) {
1110        return false;
1111    }
1112    let is_html = req
1113        .headers()
1114        .get(header::ACCEPT)
1115        .and_then(|v| v.to_str().ok())
1116        .is_some_and(|a| a.contains("text/html"));
1117    if !is_html {
1118        return false;
1119    }
1120    let path = req.uri().path();
1121    !EXEMPT.iter().any(|p| path.starts_with(p))
1122}
1123
1124/// Whether the request already carries the consent acknowledgement cookie.
1125fn request_has_consent(req: &Request<Body>) -> bool {
1126    req.headers()
1127        .get(header::COOKIE)
1128        .and_then(|v| v.to_str().ok())
1129        .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1130}
1131
1132/// Pre-access consent gate. When a banner is configured, browser page navigations
1133/// must acknowledge it (recorded in a session cookie) before proceeding. A no-op
1134/// when unconfigured, so default deployments are unaffected.
1135async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1136    let Some(text) = consent_banner_text() else {
1137        return next.run(req).await;
1138    };
1139    if !consent_gate_applies(&req) || request_has_consent(&req) {
1140        return next.run(req).await;
1141    }
1142    let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1143    render_consent_page(&text, next_path)
1144}
1145
1146/// Minimal escaping for embedding operator/config text into the banner HTML.
1147fn html_escape_consent(s: &str) -> String {
1148    s.replace('&', "&amp;")
1149        .replace('<', "&lt;")
1150        .replace('>', "&gt;")
1151        .replace('"', "&quot;")
1152}
1153
1154/// Render the consent interstitial with an "I Agree" action that records
1155/// acknowledgement and returns the user to where they were headed.
1156fn render_consent_page(text: &str, next_path: &str) -> Response {
1157    // Only accept a safe same-origin relative path as the return target.
1158    let safe_next = if next_path.starts_with('/')
1159        && !next_path.starts_with("//")
1160        && !next_path.contains("://")
1161        && !next_path.starts_with("/auth/")
1162    {
1163        next_path
1164    } else {
1165        "/"
1166    };
1167    let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1168    let body = format!(
1169        r#"<!doctype html><html><head><meta charset="utf-8">
1170<meta name="viewport" content="width=device-width, initial-scale=1">
1171<title>Notice and Consent — OxideSLOC</title>
1172<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1173h1{{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}}
1174.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1175.agree:hover{{background:#a04d27}}</style>
1176</head><body>
1177<h1>Notice and Consent</h1>
1178<div class="notice">{}</div>
1179<a class="agree" href="{}">I Agree</a>
1180</body></html>"#,
1181        html_escape_consent(text),
1182        accept_url
1183    );
1184    (StatusCode::OK, Html(body)).into_response()
1185}
1186
1187/// Emit operator-facing warnings for insecure server-mode configurations.
1188/// Pure side-effect (stdout); no bearing on the returned config values.
1189// The bools are independent configuration facts read from the resolved config, not
1190// a mode enum — folding them into a struct just to pass them here would add
1191// ceremony without clarity. Scope the allow to this diagnostic helper.
1192#[allow(clippy::fn_params_excessive_bools)]
1193fn emit_server_mode_warnings(
1194    server_mode: bool,
1195    api_keys_empty: bool,
1196    tls_enabled: bool,
1197    trust_proxy: bool,
1198    trusted_proxy_ips: &[IpAddr],
1199) {
1200    if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1201        // Absence of a key is a hard startup failure in server mode (enforced by the
1202        // caller, `serve`). The only exception is an explicit operator opt-in via
1203        // SLOC_ALLOW_UNAUTHENTICATED=1 for trusted-LAN testing — warn loudly then.
1204        println!(
1205            "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1206             authentication. Every web endpoint is publicly reachable. Do NOT use this \
1207             outside a trusted, isolated network."
1208        );
1209    }
1210    if server_mode && !tls_enabled {
1211        println!(
1212            "WARNING: TLS is not configured. Traffic is cleartext. \
1213             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1214             or terminate TLS at a reverse proxy (nginx, caddy)."
1215        );
1216    }
1217    if server_mode {
1218        println!(
1219            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1220             to restrict cross-origin access (comma-separated)."
1221        );
1222    }
1223    emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1224    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1225        println!(
1226            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1227             DISABLED for all git operations. Remove this variable before production use."
1228        );
1229    }
1230}
1231
1232/// Emit the reverse-proxy / X-Forwarded-For trust advisory for server mode.
1233fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1234    if trust_proxy {
1235        if trusted_proxy_ips.is_empty() {
1236            println!(
1237                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1238                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1239                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1240            );
1241        } else {
1242            println!(
1243                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1244                trusted_proxy_ips
1245                    .iter()
1246                    .map(std::string::ToString::to_string)
1247                    .collect::<Vec<_>>()
1248                    .join(", ")
1249            );
1250        }
1251    } else if server_mode {
1252        println!(
1253            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1254             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1255             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1256             enable per-client rate limiting via X-Forwarded-For."
1257        );
1258    }
1259}
1260
1261fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1262    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1263        .or_else(|_| std::env::var("SLOC_API_KEY"))
1264        .unwrap_or_default()
1265        .split(',')
1266        .map(str::trim)
1267        .filter(|s| !s.is_empty())
1268        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1269        .collect();
1270    let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1271        std::env::var("SLOC_API_KEYS_READONLY")
1272            .unwrap_or_default()
1273            .split(',')
1274            .map(str::trim)
1275            .filter(|s| !s.is_empty())
1276            .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1277            .collect();
1278    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1279    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1280    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1281    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1282    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1283        .unwrap_or_default()
1284        .split(',')
1285        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1286        .collect();
1287    emit_server_mode_warnings(
1288        server_mode,
1289        api_keys.is_empty(),
1290        tls_enabled,
1291        trust_proxy,
1292        &trusted_proxy_ips,
1293    );
1294    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1295        .ok()
1296        .and_then(|v| v.parse::<u32>().ok())
1297        .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1298    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1299        .ok()
1300        .and_then(|v| v.parse::<u64>().ok())
1301        .unwrap_or(3600);
1302    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1303    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1304    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1305    let default_rpm: usize = if server_mode { 120 } else { 600 };
1306    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1307        .ok()
1308        .and_then(|v| v.parse::<usize>().ok())
1309        .unwrap_or(default_rpm);
1310    let rate_limiter = Arc::new(IpRateLimiter::new(
1311        Duration::from_mins(1),
1312        rate_limit_rpm,
1313        auth_lockout_threshold,
1314        Duration::from_secs(auth_lockout_secs),
1315    ));
1316    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1317    RuntimeSecurityConfig {
1318        api_keys,
1319        readonly_api_keys,
1320        tls_cert,
1321        tls_key,
1322        tls_enabled,
1323        trust_proxy,
1324        trusted_proxy_ips,
1325        rate_limiter,
1326    }
1327}
1328
1329/// # Errors
1330///
1331/// Returns an error if the server fails to bind to the configured address or
1332/// if the TLS configuration cannot be loaded.
1333///
1334/// # Panics
1335///
1336/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1337#[allow(clippy::too_many_lines)]
1338pub async fn serve(config: AppConfig) -> Result<()> {
1339    let bind_address = config.web.bind_address.clone();
1340    let server_mode = config.web.server_mode;
1341    let output_root = resolve_output_root(None);
1342    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1343    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1344        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1345    let mut registry = ScanRegistry::load(&registry_path);
1346    registry.prune_stale();
1347    let _ = registry.save(&registry_path);
1348
1349    let sec = load_runtime_security_config(server_mode);
1350    // Security posture: refuse to start an unauthenticated network-facing server. A server-mode
1351    // launch with no API key would expose every endpoint publicly; fail closed unless the
1352    // operator has explicitly accepted the risk via SLOC_ALLOW_UNAUTHENTICATED=1.
1353    if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1354        audit::record(
1355            "server_start_refused",
1356            "denied",
1357            &[(
1358                "reason",
1359                "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1360            )],
1361        );
1362        anyhow::bail!(
1363            "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1364             (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1365             unauthenticated server on a trusted, isolated network, explicitly set \
1366             SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1367        );
1368    }
1369    if server_mode && sec.api_keys.is_empty() {
1370        audit::record("server_start_unauthenticated", "warning", &[]);
1371    }
1372    spawn_upload_staging_cleanup();
1373
1374    let git_clones_dir = resolve_git_clones_dir(&output_root);
1375    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1376        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1377    let schedules = ScheduleStore::load(&schedules_path);
1378    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1379        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1380    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1381    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1382        |_| output_root.join("confluence_config.json"),
1383        PathBuf::from,
1384    );
1385    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1386    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1387        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1388    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1389    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1390        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1391    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1392
1393    let state = AppState {
1394        base_config: config,
1395        artifacts: Arc::new(Mutex::new(HashMap::new())),
1396        async_runs: Arc::new(Mutex::new(HashMap::new())),
1397        registry: Arc::new(Mutex::new(registry)),
1398        registry_path,
1399        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1400        server_mode,
1401        allow_unauthenticated: allow_unauthenticated_server_mode(),
1402        tls_enabled: sec.tls_enabled,
1403        api_keys: Arc::new(sec.api_keys),
1404        readonly_api_keys: Arc::new(sec.readonly_api_keys),
1405        rate_limiter: sec.rate_limiter,
1406        trust_proxy: sec.trust_proxy,
1407        trusted_proxy_ips: sec.trusted_proxy_ips,
1408        git_clones_dir,
1409        schedules: Arc::new(Mutex::new(schedules)),
1410        schedules_path,
1411        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1412        scan_profiles_path,
1413        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1414        confluence: Arc::new(Mutex::new(confluence)),
1415        confluence_path,
1416        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1417        watched_dirs_path,
1418        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1419        cleanup_policy_path,
1420        cleanup_task_handle: Arc::new(Mutex::new(None)),
1421    };
1422
1423    restart_poll_schedules(&state).await;
1424    warn_insecure_gitlab_webhooks(&state).await;
1425
1426    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1427    {
1428        let enabled = state
1429            .cleanup_policy
1430            .lock()
1431            .await
1432            .policy
1433            .as_ref()
1434            .is_some_and(|p| p.enabled);
1435        if enabled {
1436            let handle = spawn_cleanup_policy_task(state.clone());
1437            *state.cleanup_task_handle.lock().await = Some(handle);
1438        }
1439    }
1440
1441    let app = build_router(state.clone());
1442
1443    // Try the configured port first, then step up through a few alternatives.
1444    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1445    // kernel zombie (visible in netstat but owned by no living process).  Rather
1446    // than failing, we auto-select the next free port and tell the user.
1447    let preferred: SocketAddr = bind_address
1448        .parse()
1449        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1450
1451    // Opt-in transport-encryption gate: refuse to expose a network-facing (non-
1452    // loopback) listener in cleartext when TLS enforcement is requested. Off by
1453    // default; enable with SLOC_REQUIRE_TLS=1 or SLOC_HARDENED=1. Loopback binds
1454    // (including reverse-proxy-terminated setups) are always allowed.
1455    if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1456        audit::record(
1457            "server_start_refused",
1458            "denied",
1459            &[("reason", "TLS required for non-loopback bind")],
1460        );
1461        anyhow::bail!(
1462            "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1463             SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1464             a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1465        );
1466    }
1467
1468    let (listener, addr) = {
1469        let candidates = (0u16..=9).map(|offset| {
1470            let mut a = preferred;
1471            a.set_port(preferred.port().saturating_add(offset));
1472            a
1473        });
1474        let mut found = None;
1475        for candidate in candidates {
1476            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1477                found = Some((l, candidate));
1478                break;
1479            }
1480        }
1481        found.ok_or_else(|| {
1482            anyhow::anyhow!(
1483                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1484                bind_address,
1485                preferred.port(),
1486                preferred.port().saturating_add(9)
1487            )
1488        })?
1489    };
1490    if addr != preferred {
1491        eprintln!(
1492            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1493             using {} instead.",
1494            preferred.port(),
1495            addr.port()
1496        );
1497    }
1498
1499    if sec.tls_enabled {
1500        let cert_path = sec
1501            .tls_cert
1502            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1503        let key_path = sec
1504            .tls_key
1505            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1506        let tls_config = build_tls_config(&cert_path, &key_path)
1507            .context("failed to load TLS certificate/key")?;
1508        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1509
1510        let url = format!("https://{addr}/");
1511        println!("OxideSLOC server running at {url} (TLS)");
1512        if let Some(lan) = wildcard_lan_url(&url) {
1513            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1514        }
1515        println!("Use Ctrl+C to stop.");
1516
1517        return serve_tls(listener, app, acceptor, server_mode).await;
1518    }
1519
1520    let url = format!("http://{addr}/");
1521    log_startup_url(&url, server_mode);
1522
1523    axum::serve(
1524        listener,
1525        app.into_make_service_with_connect_info::<SocketAddr>(),
1526    )
1527    .with_graceful_shutdown(shutdown_signal(server_mode))
1528    .await
1529    .context("web server terminated unexpectedly")
1530}
1531
1532/// Discover the primary non-loopback IPv4 address by asking the OS which
1533/// outbound interface it would use to reach a public address.  No packets are
1534/// sent — the UDP socket is only used to query the routing table.
1535fn primary_lan_ip() -> Option<String> {
1536    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1537    socket.connect("8.8.8.8:80").ok()?;
1538    let addr = socket.local_addr().ok()?;
1539    let ip = addr.ip();
1540    if ip.is_loopback() {
1541        return None;
1542    }
1543    Some(ip.to_string())
1544}
1545
1546/// If `url` binds a wildcard address (`0.0.0.0` or `[::]`), return the same URL
1547/// with the primary LAN IP substituted, so the startup log shows a client-usable
1548/// address alongside the bind address. Returns `None` for concrete binds or when
1549/// no routable LAN address can be determined (e.g. loopback-only / no default route).
1550fn wildcard_lan_url(url: &str) -> Option<String> {
1551    if url.contains("0.0.0.0") {
1552        primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1553    } else if url.contains("[::]") {
1554        primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1555    } else {
1556        None
1557    }
1558}
1559
1560/// Print the startup URL and, in local mode, open the browser and schedule it.
1561fn log_startup_url(url: &str, server_mode: bool) {
1562    if server_mode {
1563        println!("OxideSLOC server running at {url}");
1564        if let Some(lan) = wildcard_lan_url(url) {
1565            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1566        }
1567        println!("Use Ctrl+C to stop.");
1568    } else {
1569        println!("OxideSLOC local web UI running at {url}");
1570        println!("Press Ctrl+C to stop the server.");
1571        let open_url = url.to_owned();
1572        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1573    }
1574}
1575
1576/// Open the given URL in the default system browser.
1577fn open_browser_tab(url: &str) {
1578    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1579    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1580    // first quoted token as a window title — both are fragile and shell-parsed. The
1581    // url.dll handler receives the URL as a single, non-shell argument.
1582    #[cfg(target_os = "windows")]
1583    let _ = std::process::Command::new("rundll32")
1584        .args(["url.dll,FileProtocolHandler", url])
1585        .stdout(Stdio::null())
1586        .stderr(Stdio::null())
1587        .spawn();
1588    #[cfg(target_os = "macos")]
1589    let _ = std::process::Command::new("open")
1590        .arg(url)
1591        .stdout(Stdio::null())
1592        .stderr(Stdio::null())
1593        .spawn();
1594    #[cfg(target_os = "linux")]
1595    let _ = std::process::Command::new("xdg-open")
1596        .arg(url)
1597        .stdout(Stdio::null())
1598        .stderr(Stdio::null())
1599        .spawn();
1600}
1601
1602/// Graceful-shutdown future: resolves on Ctrl-C.
1603async fn shutdown_signal(server_mode: bool) {
1604    if tokio::signal::ctrl_c().await.is_ok() {
1605        println!();
1606        if server_mode {
1607            println!("Shutting down OxideSLOC server...");
1608        } else {
1609            println!("Shutting down OxideSLOC local web UI...");
1610        }
1611        println!("Server stopped cleanly.");
1612    }
1613}
1614
1615/// Load a rustls `ServerConfig` from PEM certificate and key files.
1616fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1617    use rustls_pki_types::pem::PemObject;
1618    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1619
1620    let cert_bytes =
1621        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1622    let key_bytes =
1623        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1624
1625    let cert_chain: Vec<CertificateDer<'static>> =
1626        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1627            .collect::<std::result::Result<_, _>>()
1628            .context("failed to parse TLS certificates")?;
1629
1630    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1631        .context("failed to parse TLS private key")?;
1632
1633    // Explicitly pin the accepted protocol versions to TLS 1.2 and 1.3 (these are
1634    // rustls's safe defaults; stated here so the accepted set is auditable). rustls
1635    // ships only modern AEAD cipher suites — no CBC/RC4/3DES — so no suite pinning is
1636    // needed to exclude weak ciphers.
1637    let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1638        &rustls::version::TLS13,
1639        &rustls::version::TLS12,
1640    ]);
1641
1642    // Opt-in mutual TLS: when SLOC_TLS_CLIENT_CA points to a PEM CA bundle, require
1643    // every client to present a certificate that chains to it — a transport-layer
1644    // factor on top of the application API key. Unset = no client auth (prior
1645    // behaviour).
1646    let config = match client_cert_verifier()? {
1647        Some(verifier) => builder
1648            .with_client_cert_verifier(verifier)
1649            .with_single_cert(cert_chain, key),
1650        None => builder
1651            .with_no_client_auth()
1652            .with_single_cert(cert_chain, key),
1653    };
1654    config.context("failed to build TLS server config")
1655}
1656
1657/// Build a client-certificate verifier when `SLOC_TLS_CLIENT_CA` is configured,
1658/// enabling mutual TLS. Returns `None` (no client auth) when unset — the default.
1659fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1660    use rustls_pki_types::pem::PemObject;
1661    use rustls_pki_types::CertificateDer;
1662
1663    let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1664        .ok()
1665        .filter(|s| !s.is_empty())
1666    else {
1667        return Ok(None);
1668    };
1669    let ca_bytes = fs::read(&ca_path)
1670        .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1671    let mut roots = rustls::RootCertStore::empty();
1672    for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1673        let cert = cert.context("failed to parse client CA certificate")?;
1674        roots
1675            .add(cert)
1676            .context("failed to add client CA certificate to root store")?;
1677    }
1678    let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1679        .build()
1680        .context("failed to build client certificate verifier")?;
1681    Ok(Some(verifier))
1682}
1683
1684/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1685async fn serve_tls(
1686    listener: tokio::net::TcpListener,
1687    app: Router,
1688    acceptor: tokio_rustls::TlsAcceptor,
1689    server_mode: bool,
1690) -> Result<()> {
1691    use hyper_util::rt::{TokioExecutor, TokioIo};
1692    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1693    use hyper_util::service::TowerToHyperService;
1694    use tower::{Service, ServiceExt};
1695
1696    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1697
1698    loop {
1699        tokio::select! {
1700            biased;
1701            _ = tokio::signal::ctrl_c() => {
1702                println!();
1703                if server_mode {
1704                    println!("Shutting down OxideSLOC server...");
1705                } else {
1706                    println!("Shutting down OxideSLOC local web UI...");
1707                }
1708                println!("Server stopped cleanly.");
1709                return Ok(());
1710            }
1711            result = listener.accept() => {
1712                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1713                let acceptor = acceptor.clone();
1714                let mut factory = make_svc.clone();
1715
1716                tokio::spawn(async move {
1717                    let tls = match acceptor.accept(tcp).await {
1718                        Ok(s) => s,
1719                        Err(e) => {
1720                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1721                            return;
1722                        }
1723                    };
1724                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1725                        Ok(f) => match Service::call(f, peer_addr).await {
1726                            Ok(s) => s,
1727                            Err(_) => return,
1728                        },
1729                        Err(_) => return,
1730                    };
1731                    let io = TokioIo::new(tls);
1732                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1733                        .serve_connection(io, TowerToHyperService::new(svc))
1734                        .await
1735                    {
1736                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1737                    }
1738                });
1739            }
1740        }
1741    }
1742}
1743
1744// auth moved to auth.rs
1745
1746fn build_cors_layer(server_mode: bool) -> CorsLayer {
1747    if server_mode {
1748        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1749            .unwrap_or_default()
1750            .split(',')
1751            .filter(|s| !s.is_empty())
1752            .filter_map(|s| s.trim().parse().ok())
1753            .collect();
1754        if allowed.is_empty() {
1755            return CorsLayer::new();
1756        }
1757        CorsLayer::new()
1758            .allow_origin(AllowOrigin::list(allowed))
1759            .allow_methods(AllowMethods::list([
1760                axum::http::Method::GET,
1761                axum::http::Method::POST,
1762            ]))
1763            .allow_headers(AllowHeaders::list([
1764                axum::http::header::AUTHORIZATION,
1765                axum::http::header::CONTENT_TYPE,
1766            ]))
1767    } else {
1768        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1769            let s = origin.to_str().unwrap_or("");
1770            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1771        }))
1772    }
1773}
1774
1775async fn add_security_headers(
1776    State(state): State<AppState>,
1777    mut req: Request<Body>,
1778    next: Next,
1779) -> Response {
1780    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1781    req.extensions_mut().insert(CspNonce(nonce.clone()));
1782    let mut resp = next.run(req).await;
1783    inject_page_fade_into_html(&mut resp, &nonce).await;
1784    let h = resp.headers_mut();
1785    // frame-ancestors defaults to deny (the UI cannot be iframed anywhere). An
1786    // operator can opt into embedding in named corporate dashboards by setting
1787    // SLOC_FRAME_ANCESTORS to a space-separated origin allowlist. X-Frame-Options
1788    // cannot express a multi-origin allowlist, so when one is configured we drop
1789    // XFO and let the CSP frame-ancestors directive govern (per-origin, and what
1790    // modern browsers honour); unset keeps the strict XFO: DENY + frame-ancestors
1791    // 'none' posture. A malformed value falls back to the safe default below.
1792    let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1793        .ok()
1794        .map(|v| v.trim().to_string())
1795        .filter(|v| !v.is_empty());
1796    if frame_ancestors.is_none() {
1797        h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1798    }
1799    let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1800    h.insert(
1801        "X-Content-Type-Options",
1802        HeaderValue::from_static("nosniff"),
1803    );
1804    h.insert(
1805        "Referrer-Policy",
1806        HeaderValue::from_static("strict-origin-when-cross-origin"),
1807    );
1808    let csp = format!(
1809        "default-src 'self'; \
1810         base-uri 'self'; \
1811         form-action 'self'; \
1812         style-src 'self' 'unsafe-inline'; \
1813         img-src 'self' data: blob:; \
1814         script-src 'self' 'nonce-{nonce}'; \
1815         font-src 'self' data:; \
1816         object-src 'none'; \
1817         frame-ancestors {frame_ancestors_directive}"
1818    );
1819    h.insert(
1820        "Content-Security-Policy",
1821        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1822            HeaderValue::from_static(
1823                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1824            )
1825        }),
1826    );
1827    h.insert(
1828        "X-Permitted-Cross-Domain-Policies",
1829        HeaderValue::from_static("none"),
1830    );
1831    h.insert(
1832        "Permissions-Policy",
1833        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1834    );
1835    h.insert(
1836        "Cross-Origin-Opener-Policy",
1837        HeaderValue::from_static("same-origin"),
1838    );
1839    h.insert(
1840        "Cross-Origin-Resource-Policy",
1841        HeaderValue::from_static("same-origin"),
1842    );
1843    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1844    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1845    h.insert(
1846        "Cross-Origin-Embedder-Policy",
1847        HeaderValue::from_static("require-corp"),
1848    );
1849    if state.tls_enabled {
1850        h.insert(
1851            "Strict-Transport-Security",
1852            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1853        );
1854    }
1855    resp
1856}
1857
1858/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1859///
1860/// On state-changing methods, browser-driven cookie-authenticated requests must
1861/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
1862/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
1863///
1864/// Deliberately exempt:
1865/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
1866/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
1867///   ambient, so it is not CSRF-exploitable.
1868/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
1869/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
1870///   a browser performing a CSRF attack always sends `Origin`.
1871async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1872    use axum::http::Method;
1873
1874    let is_state_changing = matches!(
1875        *req.method(),
1876        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1877    );
1878    let path = req.uri().path();
1879    let has_token_auth = req.headers().contains_key("X-API-Key")
1880        || req
1881            .headers()
1882            .get(header::AUTHORIZATION)
1883            .and_then(|v| v.to_str().ok())
1884            .is_some_and(|v| v.starts_with("Bearer "));
1885
1886    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1887        return next.run(req).await;
1888    }
1889
1890    let headers = req.headers();
1891    let header_str = |name: &header::HeaderName| {
1892        headers
1893            .get(name)
1894            .and_then(|v| v.to_str().ok())
1895            .map(str::to_owned)
1896    };
1897    let origin = header_str(&header::ORIGIN);
1898    let referer = header_str(&header::REFERER);
1899    let host = header_str(&header::HOST);
1900
1901    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
1902    let authority_of = |url: &str| -> Option<String> {
1903        url.split_once("://")
1904            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1905    };
1906
1907    let source_authority = origin
1908        .as_deref()
1909        .and_then(authority_of)
1910        .or_else(|| referer.as_deref().and_then(authority_of));
1911
1912    match (source_authority, host) {
1913        // Neither Origin nor Referer present: treat as a non-browser client.
1914        (None, _) => next.run(req).await,
1915        (Some(src), Some(h)) if src == h => next.run(req).await,
1916        (Some(src), host) => {
1917            tracing::warn!(
1918                event = "csrf_rejected",
1919                path = %path,
1920                origin = %src,
1921                host = ?host,
1922                "Cross-origin state-changing request rejected (CSRF guard)"
1923            );
1924            (
1925                StatusCode::FORBIDDEN,
1926                "403 Forbidden — cross-origin request rejected\n",
1927            )
1928                .into_response()
1929        }
1930    }
1931}
1932
1933/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
1934/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
1935/// is overkill — a short opacity fade gives a smooth page-to-page transition
1936/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
1937/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
1938/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
1939/// light-mode flash for dark-theme users.
1940fn page_fade_html(nonce: &str) -> String {
1941    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
1942    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
1943    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
1944    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
1945    // class) keeps it invisible from the moment this style parses — at the top of <body> —
1946    // through the entire body parse, which reads as a delay before navigation "begins"
1947    // and then a blink. Without a fill-mode the animation starts at first paint and plays
1948    // 0 -> 1 cleanly, with no pre-paint hold.
1949    const STYLE: &str = r"<style>
1950@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1951.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1952body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1953@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1954</style>";
1955    // `dark`: apply the saved dark theme before paint to avoid a light flash.
1956    // The click handler gives immediate feedback by fading the *content* out the moment a
1957    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
1958    // preventDefault or delay navigation — the browser navigates instantly and the fade
1959    // plays opportunistically during the natural fetch window, so no latency is added.
1960    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
1961    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
1962    // if the click was actually a download (no unload) or the page is restored from bfcache.
1963    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');});})();";
1964    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1965}
1966
1967/// Self-contained branded loading overlay for the heavy comparison pages (Scan
1968/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
1969/// `<script>` — meant to be spliced in immediately after `<body>`.
1970///
1971/// It pairs the spinner with a **visibility gate**: from the first byte the page
1972/// content is held at `visibility:hidden` (only the overlay paints), so the user
1973/// never sees a half-rendered flash while charts/tables are still settling. On
1974/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
1975/// still-opaque overlay, which then fades out one frame later — so the reveal is
1976/// of a finished page, with no glitch on either side of the transition.
1977///
1978/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
1979/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
1980/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
1981fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1982    const TPL: &str = r#"<style nonce="__N__">
1983html.sloc-pending body{visibility:hidden;}
1984html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1985#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%);}
1986#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1987body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1988body.pdf-mode #rpt-loading-overlay{display:none!important;}
1989.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1990.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;}
1991.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;}
1992@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1993@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1994body.dark-theme .rpt-bg-blob{opacity:.36;}
1995.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;}
1996@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1997body.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);}
1998.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1999.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2000.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2001.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));}
2002@keyframes rpt-spin{to{transform:rotate(360deg);}}
2003.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;}
2004body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2005body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2006.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2007.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;}
2008@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2009.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2010.rpt-dot:nth-child(2){animation-delay:.28s;}
2011.rpt-dot:nth-child(3){animation-delay:.56s;}
2012@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2013.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2014.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2015.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;}
2016body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2017@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;}}
2018</style>
2019<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2020<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>
2021<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2022  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2023  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2024  <div class="rpt-load-card">
2025    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2026    <div class="rpt-spinner-wrap">
2027      <div class="rpt-spinner-track"></div>
2028      <div class="rpt-spinner"></div>
2029      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2030    </div>
2031    <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>
2032    <div class="rpt-status" id="rpt-status">__LABEL__</div>
2033    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2034  </div>
2035</div>
2036<script nonce="__N__">
2037(function(){
2038  var ov=document.getElementById('rpt-loading-overlay');
2039  var root=document.documentElement;
2040  function reveal(){root.classList.remove('sloc-pending');}
2041  if(!ov){reveal();return;}
2042  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2043  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2044  var mi=0,prog=0,done=false,start=Date.now();
2045  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2046  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2047  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2048  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2049  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2050  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2051  setProg(8);
2052  var msgTimer=setInterval(nextMsg,700);
2053  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);
2054  // These pages draw charts into known SVG containers that start empty and are
2055  // filled by JS once layout is available (some only after a ResizeObserver pass
2056  // post-`load`). Treat the page as ready only once every chart container present
2057  // actually has rendered content, so the overlay never lifts on a half-drawn page.
2058  function chartsRendered(){
2059    var sel=['#cmp-tl-svg','#mc-chart'];
2060    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2061    return true;
2062  }
2063  function finish(){
2064    if(done)return;done=true;
2065    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2066    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2067    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2068    reveal();
2069    requestAnimationFrame(function(){requestAnimationFrame(function(){
2070      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2071    });});
2072  }
2073  // Wait for `load` (resources + first layout), then poll until the charts have
2074  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2075  function afterLoad(){
2076    var loadAt=Date.now();
2077    (function poll(){
2078      if(done)return;
2079      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2080        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2081        return;
2082      }
2083      requestAnimationFrame(poll);
2084    })();
2085  }
2086  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2087  // Absolute safety net: never let the gate/overlay get stuck.
2088  setTimeout(function(){if(!done)finish();},HARD_CAP);
2089})();
2090</script>"#;
2091    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2092}
2093
2094/// Shared toast-notification assets + a global PDF-export helper, spliced into
2095/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
2096/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
2097/// placed just before `</body>`.
2098///
2099/// It defines two globals:
2100/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
2101///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
2102///   toast stays up until its returned handle's `.dismiss()` is called.
2103/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
2104///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
2105///   `/export/pdf`, triggers the download, then raises a success or error toast and
2106///   restores the button. Centralising this guarantees identical, obvious feedback
2107///   everywhere instead of a silent `alert()`-only failure path.
2108fn sloc_toast_assets(nonce: &str) -> String {
2109    const TPL: &str = r#"<style nonce="__N__">
2110#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;}
2111.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);}
2112.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2113.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2114.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;}
2115.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2116.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2117.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2118.sloc-toast-success{border-color:#bfe0cc;}
2119.sloc-toast-error{border-color:#e6b3b3;}
2120.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2121.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;}
2122@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2123.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;}
2124.sloc-toast-x:hover{opacity:1;}
2125body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2126body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2127body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2128body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2129@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2130</style>
2131<script nonce="__N__">
2132(function(){
2133  if(window.slocToast)return;
2134  function wrap(){
2135    var w=document.getElementById('sloc-toast-wrap');
2136    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);}
2137    return w;
2138  }
2139  window.slocToast=function(msg,opts){
2140    opts=opts||{};
2141    var type=opts.type||'info';
2142    var loading=type==='loading';
2143    var t=document.createElement('div');
2144    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2145    t.setAttribute('role',type==='error'?'alert':'status');
2146    var ico=loading
2147      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2148      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2149    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2150    t.querySelector('.sloc-toast-msg').textContent=String(msg);
2151    wrap().appendChild(t);
2152    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2153    var gone=false,timer=null;
2154    function close(){
2155      if(gone)return;gone=true;if(timer)clearTimeout(timer);
2156      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2157      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2158    }
2159    t.querySelector('.sloc-toast-x').addEventListener('click',close);
2160    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2161    if(ttl>0)timer=setTimeout(close,ttl);
2162    return {dismiss:close,el:t};
2163  };
2164  window.slocExportPdf=function(o){
2165    o=o||{};
2166    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2167    if(btn&&btn.disabled)return;
2168    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2169    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2170    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2171      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2172      .then(function(blob){
2173        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2174        document.body.appendChild(a);a.click();document.body.removeChild(a);
2175        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2176        load.dismiss();
2177        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2178      })
2179      .catch(function(e){
2180        load.dismiss();
2181        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2182      })
2183      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2184  };
2185})();
2186</script>"#;
2187    TPL.replace("__N__", nonce)
2188}
2189
2190/// Buffer an HTML response body and splice the page fade-in right after the
2191/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
2192/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
2193/// branded loading spinner for slow renders).
2194async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2195    let is_html = resp
2196        .headers()
2197        .get(header::CONTENT_TYPE)
2198        .and_then(|v| v.to_str().ok())
2199        .is_some_and(|v| v.starts_with("text/html"));
2200    if !is_html {
2201        return;
2202    }
2203    let body = std::mem::replace(resp.body_mut(), Body::empty());
2204    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2205        return;
2206    };
2207    let html = match String::from_utf8(bytes.to_vec()) {
2208        Ok(s) => s,
2209        Err(e) => {
2210            *resp.body_mut() = Body::from(e.into_bytes());
2211            return;
2212        }
2213    };
2214    if html.contains("id=\"rpt-loading-overlay\"") {
2215        *resp.body_mut() = Body::from(html);
2216        return;
2217    }
2218    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
2219    // avoids allocating a lowercased copy of the whole document on every request.
2220    // Fall back to a case-insensitive scan only if that fails (rare/never).
2221    let insert_at = html
2222        .find("<body")
2223        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2224        .or_else(|| {
2225            let lower = html.to_ascii_lowercase();
2226            lower
2227                .find("<body")
2228                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2229        });
2230    let new_html = match insert_at {
2231        Some(at) => {
2232            let mut out = String::with_capacity(html.len() + 1024);
2233            out.push_str(&html[..at]);
2234            out.push_str(&page_fade_html(nonce));
2235            out.push_str(&html[at..]);
2236            out
2237        }
2238        None => html,
2239    };
2240    resp.headers_mut().remove(header::CONTENT_LENGTH);
2241    *resp.body_mut() = Body::from(new_html);
2242}
2243
2244async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2245    let peer_ip = req
2246        .extensions()
2247        .get::<axum::extract::ConnectInfo<SocketAddr>>()
2248        .map(|c| c.0.ip());
2249
2250    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
2251    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
2252    // header spoofing from direct connections.
2253    let ip = peer_ip
2254        .and_then(|peer| {
2255            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2256                req.headers()
2257                    .get("X-Forwarded-For")
2258                    .and_then(|v| v.to_str().ok())
2259                    .and_then(|s| s.split(',').next())
2260                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
2261            } else {
2262                None
2263            }
2264        })
2265        .or(peer_ip)
2266        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2267
2268    if !state.rate_limiter.is_allowed(ip) {
2269        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2270            path = %req.uri().path(), "Rate limit exceeded");
2271        return (
2272            StatusCode::TOO_MANY_REQUESTS,
2273            [(header::RETRY_AFTER, "60")],
2274            "429 Too Many Requests\n",
2275        )
2276            .into_response();
2277    }
2278    next.run(req).await
2279}
2280
2281async fn splash(
2282    State(state): State<AppState>,
2283    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2284) -> impl IntoResponse {
2285    let lan_ip = if state.server_mode {
2286        primary_lan_ip()
2287    } else {
2288        None
2289    };
2290    let port = state
2291        .base_config
2292        .web
2293        .bind_address
2294        .rsplit(':')
2295        .next()
2296        .and_then(|p| p.parse::<u16>().ok())
2297        .unwrap_or(4317);
2298    let has_api_key = !state.api_keys.is_empty();
2299    let template = SplashTemplate {
2300        csp_nonce,
2301        server_mode: state.server_mode,
2302        lan_ip,
2303        port,
2304        version: env!("CARGO_PKG_VERSION"),
2305        has_api_key,
2306    };
2307    Html(
2308        template
2309            .render()
2310            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2311    )
2312}
2313
2314async fn index(
2315    State(state): State<AppState>,
2316    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2317    Query(query): Query<IndexQuery>,
2318) -> impl IntoResponse {
2319    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2320        let policy = query
2321            .mixed_line_policy
2322            .unwrap_or_else(|| "code_only".to_string());
2323        let behavior = query
2324            .binary_file_behavior
2325            .unwrap_or_else(|| "skip".to_string());
2326        let cfg = ScanConfig {
2327            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2328            path: query.path.unwrap_or_default(),
2329            include_globs: query.include_globs.unwrap_or_default(),
2330            exclude_globs: query.exclude_globs.unwrap_or_default(),
2331            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2332            mixed_line_policy: policy,
2333            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2334                != Some("off"),
2335            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2336            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2337            vendor_directory_detection: query.vendor_directory_detection.as_deref()
2338                != Some("disabled"),
2339            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2340            binary_file_behavior: behavior,
2341            output_dir: query.output_dir.unwrap_or_default(),
2342            report_title: query.report_title.unwrap_or_default(),
2343            continuation_line_policy: query
2344                .continuation_line_policy
2345                .unwrap_or_else(default_each_physical_line),
2346            blank_in_block_comment_policy: query
2347                .blank_in_block_comment_policy
2348                .unwrap_or_else(default_count_as_comment),
2349            count_compiler_directives: query.count_compiler_directives.as_deref()
2350                != Some("disabled"),
2351            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2352            style_col_threshold: query
2353                .style_col_threshold
2354                .as_deref()
2355                .and_then(|s| s.parse().ok())
2356                .unwrap_or(80),
2357            style_score_threshold: query
2358                .style_score_threshold
2359                .as_deref()
2360                .and_then(|s| s.parse().ok())
2361                .unwrap_or(0),
2362            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2363            coverage_file: query.coverage_file.unwrap_or_default(),
2364            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2365            complexity_alert: query
2366                .complexity_alert
2367                .as_deref()
2368                .and_then(|s| s.parse().ok())
2369                .unwrap_or(0),
2370            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2371            activity_window: query
2372                .activity_window
2373                .as_deref()
2374                .and_then(|s| s.parse().ok())
2375                .unwrap_or(90),
2376        };
2377        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2378    } else {
2379        "{}".to_string()
2380    };
2381
2382    let git_repo = query.git_repo.unwrap_or_default();
2383    let git_ref = query.git_ref.unwrap_or_default();
2384
2385    let git_label = make_git_label(&git_repo, &git_ref);
2386    let git_output_dir = if git_label.is_empty() {
2387        String::new()
2388    } else {
2389        desktop_dir().join(&git_label).display().to_string()
2390    };
2391    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2392    let git_output_dir_json =
2393        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2394
2395    let template = IndexTemplate {
2396        version: env!("CARGO_PKG_VERSION"),
2397        prefill_json,
2398        csp_nonce,
2399        git_repo,
2400        git_ref,
2401        git_label_json,
2402        git_output_dir_json,
2403        server_mode: state.server_mode,
2404    };
2405
2406    Html(
2407        template
2408            .render()
2409            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2410    )
2411}
2412
2413async fn scan_setup_handler(
2414    State(state): State<AppState>,
2415    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2416) -> impl IntoResponse {
2417    let recent_scans_json = {
2418        let arr: Vec<serde_json::Value> = {
2419            let reg = state.registry.lock().await;
2420            reg.entries
2421                .iter()
2422                .rev()
2423                .take(6)
2424                .map(|e| {
2425                    let run_dir = e
2426                        .html_path
2427                        .as_ref()
2428                        .or(e.json_path.as_ref())
2429                        .and_then(|p| p.parent().map(PathBuf::from));
2430                    let config_val: Option<serde_json::Value> = run_dir
2431                        .and_then(|d| find_scan_config_in_dir(&d))
2432                        .and_then(|p| fs::read_to_string(&p).ok())
2433                        .and_then(|s| serde_json::from_str(&s).ok());
2434                    serde_json::json!({
2435                        "project_label": e.project_label,
2436                        "timestamp": fmt_la_time(e.timestamp_utc),
2437                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2438                        "config": config_val,
2439                    })
2440                })
2441                .collect()
2442        };
2443        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2444    };
2445
2446    let template = ScanSetupTemplate {
2447        version: env!("CARGO_PKG_VERSION"),
2448        recent_scans_json,
2449        csp_nonce,
2450    };
2451    Html(
2452        template
2453            .render()
2454            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2455    )
2456}
2457
2458async fn healthz() -> &'static str {
2459    "ok"
2460}
2461
2462async fn api_version_handler() -> impl IntoResponse {
2463    axum::Json(serde_json::json!({
2464        "name": "oxide-sloc",
2465        "version": env!("CARGO_PKG_VERSION"),
2466    }))
2467}
2468
2469// ── Prometheus metrics ────────────────────────────────────────────────────────
2470
2471fn prom_runs_total() -> &'static prometheus::IntCounter {
2472    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2473    COUNTER.get_or_init(|| {
2474        prometheus::register_int_counter!(
2475            "oxide_sloc_runs_total",
2476            "Total number of completed analysis runs"
2477        )
2478        .expect("failed to register oxide_sloc_runs_total counter")
2479    })
2480}
2481
2482async fn metrics_handler() -> impl IntoResponse {
2483    use prometheus::Encoder as _;
2484    let mut buf = Vec::new();
2485    let encoder = prometheus::TextEncoder::new();
2486    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2487    (
2488        [(
2489            axum::http::header::CONTENT_TYPE,
2490            "text/plain; version=0.0.4; charset=utf-8",
2491        )],
2492        buf,
2493    )
2494}
2495
2496static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2497
2498async fn openapi_yaml_handler() -> impl IntoResponse {
2499    (
2500        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2501        OPENAPI_YAML,
2502    )
2503}
2504
2505static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2506static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2507
2508async fn llms_txt_handler() -> impl IntoResponse {
2509    (
2510        [
2511            (
2512                axum::http::header::CONTENT_TYPE,
2513                "text/plain; charset=utf-8",
2514            ),
2515            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2516        ],
2517        LLMS_TXT,
2518    )
2519}
2520
2521async fn llms_full_txt_handler() -> impl IntoResponse {
2522    (
2523        [
2524            (
2525                axum::http::header::CONTENT_TYPE,
2526                "text/plain; charset=utf-8",
2527            ),
2528            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2529        ],
2530        LLMS_FULL_TXT,
2531    )
2532}
2533
2534async fn api_docs_handler(
2535    State(state): State<AppState>,
2536    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2537) -> impl IntoResponse {
2538    let has_api_key = !state.api_keys.is_empty();
2539    Html(
2540        ApiDocsTemplate {
2541            has_api_key,
2542            csp_nonce,
2543            version: env!("CARGO_PKG_VERSION"),
2544        }
2545        .render()
2546        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2547    )
2548}
2549
2550async fn chart_js_handler() -> impl IntoResponse {
2551    (
2552        [
2553            (
2554                header::CONTENT_TYPE,
2555                "application/javascript; charset=utf-8",
2556            ),
2557            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2558        ],
2559        CHART_JS,
2560    )
2561}
2562
2563async fn report_chart_js_handler() -> impl IntoResponse {
2564    (
2565        [
2566            (
2567                header::CONTENT_TYPE,
2568                "application/javascript; charset=utf-8",
2569            ),
2570            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2571        ],
2572        REPORT_CHART_JS,
2573    )
2574}
2575
2576#[derive(Debug, Deserialize)]
2577struct AnalyzeForm {
2578    path: String,
2579    git_repo: Option<String>,
2580    git_ref: Option<String>,
2581    mixed_line_policy: Option<MixedLinePolicy>,
2582    python_docstrings_as_comments: Option<String>,
2583    generated_file_detection: Option<String>,
2584    minified_file_detection: Option<String>,
2585    vendor_directory_detection: Option<String>,
2586    include_lockfiles: Option<String>,
2587    binary_file_behavior: Option<BinaryFileBehavior>,
2588    output_dir: Option<String>,
2589    report_title: Option<String>,
2590    report_header_footer: Option<String>,
2591    include_globs: Option<String>,
2592    exclude_globs: Option<String>,
2593    submodule_breakdown: Option<String>,
2594    coverage_file: Option<String>,
2595    continuation_line_policy: Option<ContinuationLinePolicy>,
2596    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2597    count_compiler_directives: Option<String>,
2598    style_col_threshold: Option<String>,
2599    style_analysis_enabled: Option<String>,
2600    style_score_threshold: Option<String>,
2601    style_lang_scope: Option<String>,
2602    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2603    cocomo_mode: Option<String>,
2604    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2605    complexity_alert: Option<String>,
2606    /// Whether to exclude duplicate files from displayed SLOC totals.
2607    exclude_duplicates: Option<String>,
2608    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2609    activity_window: Option<String>,
2610}
2611
2612#[allow(clippy::struct_excessive_bools)]
2613#[derive(Debug, Serialize, Deserialize, Clone)]
2614struct ScanConfig {
2615    oxide_sloc_version: String,
2616    path: String,
2617    include_globs: String,
2618    exclude_globs: String,
2619    submodule_breakdown: bool,
2620    mixed_line_policy: String,
2621    python_docstrings_as_comments: bool,
2622    generated_file_detection: bool,
2623    minified_file_detection: bool,
2624    vendor_directory_detection: bool,
2625    include_lockfiles: bool,
2626    binary_file_behavior: String,
2627    output_dir: String,
2628    report_title: String,
2629    // IEEE 1045-1992 and advanced fields added in later release
2630    #[serde(default = "default_each_physical_line")]
2631    continuation_line_policy: String,
2632    #[serde(default = "default_count_as_comment")]
2633    blank_in_block_comment_policy: String,
2634    #[serde(default = "default_true_bool")]
2635    count_compiler_directives: bool,
2636    #[serde(default = "default_true_bool")]
2637    style_analysis_enabled: bool,
2638    #[serde(default = "default_style_col_threshold")]
2639    style_col_threshold: u16,
2640    #[serde(default)]
2641    style_score_threshold: u8,
2642    #[serde(default = "default_all_scope")]
2643    style_lang_scope: String,
2644    #[serde(default)]
2645    coverage_file: String,
2646    #[serde(default = "default_organic")]
2647    cocomo_mode: String,
2648    #[serde(default)]
2649    complexity_alert: u32,
2650    #[serde(default)]
2651    exclude_duplicates: bool,
2652    /// Git hotspots activity window in days (on by default; 0 = disabled).
2653    #[serde(default = "default_activity_window")]
2654    activity_window: u32,
2655}
2656
2657const fn default_activity_window() -> u32 {
2658    90
2659}
2660
2661fn default_each_physical_line() -> String {
2662    "each_physical_line".to_string()
2663}
2664fn default_count_as_comment() -> String {
2665    "count_as_comment".to_string()
2666}
2667const fn default_true_bool() -> bool {
2668    true
2669}
2670const fn default_style_col_threshold() -> u16 {
2671    80
2672}
2673fn default_all_scope() -> String {
2674    "all".to_string()
2675}
2676fn default_organic() -> String {
2677    "organic".to_string()
2678}
2679
2680#[derive(Debug, Deserialize, Default)]
2681struct IndexQuery {
2682    path: Option<String>,
2683    include_globs: Option<String>,
2684    exclude_globs: Option<String>,
2685    submodule_breakdown: Option<String>,
2686    mixed_line_policy: Option<String>,
2687    python_docstrings_as_comments: Option<String>,
2688    generated_file_detection: Option<String>,
2689    minified_file_detection: Option<String>,
2690    vendor_directory_detection: Option<String>,
2691    include_lockfiles: Option<String>,
2692    binary_file_behavior: Option<String>,
2693    output_dir: Option<String>,
2694    report_title: Option<String>,
2695    prefilled: Option<String>,
2696    git_repo: Option<String>,
2697    git_ref: Option<String>,
2698    // IEEE 1045-1992 and advanced fields
2699    continuation_line_policy: Option<String>,
2700    blank_in_block_comment_policy: Option<String>,
2701    count_compiler_directives: Option<String>,
2702    style_analysis_enabled: Option<String>,
2703    style_col_threshold: Option<String>,
2704    style_score_threshold: Option<String>,
2705    style_lang_scope: Option<String>,
2706    coverage_file: Option<String>,
2707    cocomo_mode: Option<String>,
2708    complexity_alert: Option<String>,
2709    exclude_duplicates: Option<String>,
2710    activity_window: Option<String>,
2711}
2712
2713#[derive(Debug, Deserialize)]
2714struct PreviewQuery {
2715    path: Option<String>,
2716    include_globs: Option<String>,
2717    exclude_globs: Option<String>,
2718}
2719
2720#[cfg(feature = "native-dialog")]
2721#[derive(Debug, Deserialize)]
2722struct PickDirectoryQuery {
2723    kind: Option<String>,
2724    current: Option<String>,
2725}
2726
2727#[cfg(not(feature = "native-dialog"))]
2728#[derive(Debug, Deserialize)]
2729struct PickDirectoryQuery {}
2730
2731#[derive(Debug, Deserialize, Default)]
2732struct ArtifactQuery {
2733    download: Option<String>,
2734}
2735
2736#[cfg(feature = "native-dialog")]
2737#[derive(Debug, Serialize)]
2738struct PickDirectoryResponse {
2739    selected_path: Option<String>,
2740    cancelled: bool,
2741}
2742
2743#[cfg(feature = "native-dialog")]
2744async fn pick_directory_handler(
2745    State(state): State<AppState>,
2746    Query(query): Query<PickDirectoryQuery>,
2747) -> Response {
2748    if state.server_mode {
2749        return StatusCode::NOT_FOUND.into_response();
2750    }
2751    // Return immediately without opening a dialog in headless / CI environments.
2752    if std::env::var("SLOC_HEADLESS").is_ok() {
2753        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2754            .into_response();
2755    }
2756
2757    let is_coverage = query.kind.as_deref() == Some("coverage");
2758    let title = match query.kind.as_deref() {
2759        Some("output") => "Select output directory",
2760        Some("reports") => "Select folder containing saved reports",
2761        Some("coverage") => "Select LCOV coverage file",
2762        _ => "Select project directory",
2763    }
2764    .to_owned();
2765    let current = query.current.clone();
2766
2767    let picked = tokio::task::spawn_blocking(move || {
2768        // Windows: attach to the foreground thread so the dialog inherits focus,
2769        // and kick off a watcher that flashes the dialog once it appears.
2770        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2771        let fg_tid = win_dialog_focus::attach_to_foreground();
2772        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2773        win_dialog_focus::flash_dialog_when_ready(title.clone());
2774
2775        let mut dialog = rfd::FileDialog::new().set_title(&title);
2776        if let Some(current) = current.as_deref() {
2777            let resolved = resolve_input_path(current);
2778            let seed = if resolved.is_dir() {
2779                Some(resolved)
2780            } else {
2781                resolved.parent().map(Path::to_path_buf)
2782            };
2783            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2784                dialog = dialog.set_directory(seed_dir);
2785            }
2786        }
2787        let result = if is_coverage {
2788            dialog
2789                .add_filter(
2790                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2791                    &["info", "lcov", "xml", "json"],
2792                )
2793                .pick_file()
2794        } else {
2795            dialog.pick_folder()
2796        };
2797
2798        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2799        win_dialog_focus::detach_from_foreground(fg_tid);
2800
2801        result
2802    })
2803    .await
2804    .unwrap_or(None);
2805
2806    Json(PickDirectoryResponse {
2807        selected_path: picked.as_ref().map(|p| display_path(p)),
2808        cancelled: picked.is_none(),
2809    })
2810    .into_response()
2811}
2812
2813#[cfg(not(feature = "native-dialog"))]
2814async fn pick_directory_handler(
2815    State(_state): State<AppState>,
2816    Query(_query): Query<PickDirectoryQuery>,
2817) -> Response {
2818    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2819}
2820
2821#[cfg(feature = "native-dialog")]
2822async fn pick_file_handler(State(state): State<AppState>) -> Response {
2823    if state.server_mode {
2824        return StatusCode::NOT_FOUND.into_response();
2825    }
2826    if std::env::var("SLOC_HEADLESS").is_ok() {
2827        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2828            .into_response();
2829    }
2830    let picked = tokio::task::spawn_blocking(|| {
2831        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2832        let fg_tid = win_dialog_focus::attach_to_foreground();
2833        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2834        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2835
2836        let result = rfd::FileDialog::new()
2837            .set_title("Select HTML report")
2838            .add_filter("HTML report", &["html"])
2839            .pick_file();
2840
2841        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2842        win_dialog_focus::detach_from_foreground(fg_tid);
2843
2844        result
2845    })
2846    .await
2847    .unwrap_or(None);
2848    Json(PickDirectoryResponse {
2849        selected_path: picked.as_ref().map(|p| display_path(p)),
2850        cancelled: picked.is_none(),
2851    })
2852    .into_response()
2853}
2854
2855#[cfg(not(feature = "native-dialog"))]
2856async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2857    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2858}
2859
2860// ── Browser-upload handlers (server mode only) ────────────────────────────────
2861
2862/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2863/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2864fn is_upload_tmp_path(path: &Path) -> bool {
2865    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2866    path.starts_with(&upload_root)
2867}
2868
2869/// Returns true when `path` is the built-in sample or test-fixture directory.
2870/// These paths ship with the server binary and are always safe to scan/preview.
2871fn is_sample_path(path: &Path) -> bool {
2872    let root = workspace_root();
2873    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2874}
2875
2876/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2877fn upload_base_dir() -> PathBuf {
2878    std::env::temp_dir().join("oxide-sloc-uploads")
2879}
2880
2881/// Returns the staging path for a given upload id inside the base dir.
2882fn upload_staging_path(id: &str) -> PathBuf {
2883    upload_base_dir().join(id)
2884}
2885
2886/// Validate basic field constraints on a directory-upload request.
2887/// Returns an error `Response` if the request should be rejected immediately.
2888#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2889fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2890    const MAX_FILES: usize = 50_000;
2891    if body.files.is_empty() {
2892        return Err((
2893            StatusCode::BAD_REQUEST,
2894            Json(serde_json::json!({"error": "No files received"})),
2895        )
2896            .into_response());
2897    }
2898    if body.files.len() > MAX_FILES {
2899        return Err((
2900            StatusCode::PAYLOAD_TOO_LARGE,
2901            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2902        )
2903            .into_response());
2904    }
2905    Ok(())
2906}
2907
2908/// Resolve or create the staging directory for a directory upload.
2909/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2910fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2911    match id {
2912        Some(id)
2913            if !id.is_empty()
2914                && id.len() <= 36
2915                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2916        {
2917            (id.to_string(), upload_staging_path(id))
2918        }
2919        _ => {
2920            let new_id = uuid::Uuid::new_v4().to_string();
2921            let staging = upload_staging_path(&new_id);
2922            (new_id, staging)
2923        }
2924    }
2925}
2926
2927/// Decode, size-check, and write one uploaded file entry into `staging`.
2928/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2929/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2930/// cleaning up `staging` before propagating the error.
2931#[allow(clippy::result_large_err)]
2932async fn stage_decoded_entry(
2933    entry: &UploadedFile,
2934    staging: &Path,
2935    total_bytes: &mut usize,
2936    project_root: &mut Option<PathBuf>,
2937) -> Result<(), Response> {
2938    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2939
2940    let Ok(data) = base64::Engine::decode(
2941        &base64::engine::general_purpose::STANDARD,
2942        entry.content.as_bytes(),
2943    ) else {
2944        return Ok(());
2945    };
2946
2947    *total_bytes += data.len();
2948    if *total_bytes > MAX_TOTAL_BYTES {
2949        return Err((
2950            StatusCode::PAYLOAD_TOO_LARGE,
2951            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2952        )
2953            .into_response());
2954    }
2955
2956    let rel = std::path::Path::new(&entry.path);
2957    if project_root.is_none() {
2958        if let Some(first) = rel.components().next() {
2959            *project_root = Some(staging.join(first.as_os_str()));
2960        }
2961    }
2962
2963    let dest = staging.join(rel);
2964    if let Some(parent) = dest.parent() {
2965        if tokio::fs::create_dir_all(parent).await.is_err() {
2966            return Err((
2967                StatusCode::INTERNAL_SERVER_ERROR,
2968                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2969            )
2970                .into_response());
2971        }
2972    }
2973
2974    if tokio::fs::write(&dest, &data).await.is_err() {
2975        return Err((
2976            StatusCode::INTERNAL_SERVER_ERROR,
2977            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2978        )
2979            .into_response());
2980    }
2981
2982    Ok(())
2983}
2984
2985/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2986/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2987/// an error `Response` on failure (staging dir is cleaned up before returning).
2988async fn write_upload_files(
2989    files: &[UploadedFile],
2990    staging: &Path,
2991    upload_id: &str,
2992) -> Result<(usize, Option<PathBuf>), Response> {
2993    let mut total_bytes: usize = 0;
2994    let mut project_root: Option<PathBuf> = None;
2995
2996    for entry in files {
2997        let rel = std::path::Path::new(&entry.path);
2998        if rel
2999            .components()
3000            .any(|c| matches!(c, std::path::Component::ParentDir))
3001        {
3002            // Reject the entire upload on the first path traversal attempt.
3003            let _ = tokio::fs::remove_dir_all(staging).await;
3004            tracing::warn!(
3005                event = "upload_path_traversal",
3006                upload_id = %upload_id,
3007                path = %entry.path,
3008                "Upload rejected: path traversal component detected"
3009            );
3010            return Err((
3011                StatusCode::BAD_REQUEST,
3012                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3013            )
3014                .into_response());
3015        }
3016
3017        if let Err(resp) =
3018            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3019        {
3020            let _ = tokio::fs::remove_dir_all(staging).await;
3021            return Err(resp);
3022        }
3023    }
3024
3025    Ok((files.len(), project_root))
3026}
3027
3028/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
3029/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
3030fn parse_tarball_size_caps() -> (u64, u64) {
3031    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3032        .ok()
3033        .and_then(|v| v.parse().ok())
3034        .unwrap_or(2048_u64)
3035        * 1024
3036        * 1024;
3037    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3038        .ok()
3039        .and_then(|v| v.parse().ok())
3040        .unwrap_or(10_240_u64)
3041        * 1024
3042        * 1024;
3043    (compressed, decompressed)
3044}
3045
3046/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
3047/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
3048/// are rejected before the streaming handler is invoked.
3049fn tarball_http_body_limit_bytes() -> usize {
3050    std::env::var("SLOC_MAX_TARBALL_MB")
3051        .ok()
3052        .and_then(|v| v.parse::<usize>().ok())
3053        .unwrap_or(2048)
3054        .saturating_mul(1024 * 1024)
3055}
3056
3057/// Stream `body` into `dest_path`, enforcing `max_bytes`.
3058/// Returns the number of compressed bytes written, or an error `Response`.
3059/// Cleans up `dest_path` on error.
3060#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3061async fn stream_body_to_file(
3062    body: axum::body::Body,
3063    dest_path: &Path,
3064    max_bytes: u64,
3065) -> Result<u64, Response> {
3066    use http_body_util::BodyExt as _;
3067    use tokio::io::AsyncWriteExt as _;
3068
3069    let mut file = match tokio::fs::File::create(dest_path).await {
3070        Ok(f) => f,
3071        Err(e) => {
3072            tracing::error!(
3073                event = "upload_io_error",
3074                "failed to create tarball temp file: {e}"
3075            );
3076            return Err((
3077                StatusCode::INTERNAL_SERVER_ERROR,
3078                Json(serde_json::json!({"error": "Upload initialization failed"})),
3079            )
3080                .into_response());
3081        }
3082    };
3083
3084    let mut body = body;
3085    let mut written: u64 = 0;
3086    loop {
3087        match body.frame().await {
3088            None => break,
3089            Some(Err(e)) => {
3090                let _ = tokio::fs::remove_file(dest_path).await;
3091                return Err((
3092                    StatusCode::BAD_REQUEST,
3093                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3094                )
3095                    .into_response());
3096            }
3097            Some(Ok(frame)) => {
3098                if let Ok(data) = frame.into_data() {
3099                    written += data.len() as u64;
3100                    if written > max_bytes {
3101                        let _ = tokio::fs::remove_file(dest_path).await;
3102                        return Err((
3103                            StatusCode::PAYLOAD_TOO_LARGE,
3104                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3105                        )
3106                            .into_response());
3107                    }
3108                    if let Err(e) = file.write_all(&data).await {
3109                        let _ = tokio::fs::remove_file(dest_path).await;
3110                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3111                        return Err((
3112                            StatusCode::INTERNAL_SERVER_ERROR,
3113                            Json(serde_json::json!({"error": "Upload write failed"})),
3114                        )
3115                            .into_response());
3116                    }
3117                }
3118            }
3119        }
3120    }
3121    drop(file);
3122    Ok(written)
3123}
3124
3125/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
3126/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
3127/// on failure (staging dir is cleaned up before returning).
3128#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3129async fn extract_tarball_to_staging(
3130    tarball_path: &Path,
3131    staging: &Path,
3132    max_decompressed_bytes: u64,
3133) -> Result<(), Response> {
3134    let staging_clone = staging.to_path_buf();
3135    let tarball_clone = tarball_path.to_path_buf();
3136    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3137        let file = std::fs::File::open(&tarball_clone)?;
3138        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3139        let limited = SizeLimitReader {
3140            inner: gz,
3141            remaining: max_decompressed_bytes,
3142        };
3143        let mut archive = tar::Archive::new(limited);
3144        archive.set_overwrite(true);
3145        archive.set_preserve_permissions(false);
3146        std::fs::create_dir_all(&staging_clone)?;
3147        archive.unpack(&staging_clone)?;
3148        Ok(())
3149    })
3150    .await;
3151    let _ = tokio::fs::remove_file(tarball_path).await;
3152
3153    match extract_result {
3154        Ok(Ok(())) => Ok(()),
3155        Ok(Err(e)) => {
3156            let _ = tokio::fs::remove_dir_all(staging).await;
3157            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3158            tracing::warn!(
3159                event = "upload_extract_error",
3160                "tarball extraction failed: {e:#}"
3161            );
3162            let (status, msg) = if is_size_limit {
3163                (
3164                    StatusCode::PAYLOAD_TOO_LARGE,
3165                    "Archive exceeds the decompressed size limit",
3166                )
3167            } else {
3168                (StatusCode::BAD_REQUEST, "Failed to extract archive")
3169            };
3170            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3171        }
3172        Err(e) => {
3173            let _ = tokio::fs::remove_dir_all(staging).await;
3174            tracing::error!(
3175                event = "upload_extract_panic",
3176                "tarball extraction task panicked: {e}"
3177            );
3178            Err((
3179                StatusCode::INTERNAL_SERVER_ERROR,
3180                Json(serde_json::json!({"error": "Archive extraction failed"})),
3181            )
3182                .into_response())
3183        }
3184    }
3185}
3186
3187/// If `staging` contains exactly one top-level directory, return its path
3188/// (the common case when the archive was created with `webkitRelativePath`).
3189/// Otherwise return `None`.
3190async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3191    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3192    let first = entries.next_entry().await.ok()??;
3193    if !first.path().is_dir() {
3194        return None;
3195    }
3196    if entries.next_entry().await.unwrap_or(None).is_some() {
3197        return None;
3198    }
3199    Some(first.path())
3200}
3201
3202/// Request body for `POST /api/upload-directory`.
3203///
3204/// Each entry carries a relative path (identical to the browser's
3205/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
3206/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
3207/// avoids pulling in a `multipart` library that is not in the vendor archive.
3208#[derive(Deserialize)]
3209struct UploadDirRequest {
3210    files: Vec<UploadedFile>,
3211    /// If provided, append this batch to an existing upload session instead of
3212    /// creating a new staging directory. Must be a plain UUID (no path separators).
3213    upload_id: Option<String>,
3214}
3215
3216#[derive(Deserialize)]
3217struct UploadedFile {
3218    /// `webkitRelativePath` value from the browser File object.
3219    path: String,
3220    /// Raw file bytes encoded as standard base64.
3221    content: String,
3222}
3223
3224/// POST /api/upload-directory
3225///
3226/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
3227/// Saves all files to a temp staging directory preserving their relative paths,
3228/// then returns the server-side root directory path so the caller can populate
3229/// the scan-path field and run a normal analysis.
3230///
3231/// Only available in server mode; returns 404 in local mode (use the native
3232/// rfd dialog instead).
3233async fn upload_directory_handler(
3234    State(state): State<AppState>,
3235    Json(body): Json<UploadDirRequest>,
3236) -> Response {
3237    if !state.server_mode {
3238        return StatusCode::NOT_FOUND.into_response();
3239    }
3240    if let Err(resp) = validate_upload_dir_request(&body) {
3241        return resp;
3242    }
3243    // Reuse an existing staging dir when the client sends a continuation batch,
3244    // otherwise create a fresh one. Validate the id to prevent path traversal.
3245    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3246    match write_upload_files(&body.files, &staging, &upload_id).await {
3247        Ok((file_count, project_root)) => {
3248            let scan_root = project_root.unwrap_or_else(|| staging.clone());
3249            Json(serde_json::json!({
3250                "tmp_path": scan_root.to_string_lossy(),
3251                "file_count": file_count,
3252                "upload_id": upload_id.clone()
3253            }))
3254            .into_response()
3255        }
3256        Err(resp) => resp,
3257    }
3258}
3259
3260/// Request body for `POST /api/upload-file`.
3261#[derive(Deserialize)]
3262struct UploadFileRequest {
3263    /// Original filename (used only to preserve the extension).
3264    filename: String,
3265    /// File bytes encoded as standard base64.
3266    content: String,
3267}
3268
3269/// POST /api/upload-file
3270///
3271/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
3272/// Accepts `{ "filename": "…", "content": "<base64>" }`.
3273/// Only available in server mode.
3274async fn upload_file_handler(
3275    State(state): State<AppState>,
3276    Json(body): Json<UploadFileRequest>,
3277) -> Response {
3278    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
3279
3280    if !state.server_mode {
3281        return StatusCode::NOT_FOUND.into_response();
3282    }
3283
3284    let Ok(data) = base64::Engine::decode(
3285        &base64::engine::general_purpose::STANDARD,
3286        body.content.as_bytes(),
3287    ) else {
3288        return (
3289            StatusCode::BAD_REQUEST,
3290            Json(serde_json::json!({"error": "Invalid base64 content"})),
3291        )
3292            .into_response();
3293    };
3294
3295    if data.len() > MAX_FILE_BYTES {
3296        return (
3297            StatusCode::PAYLOAD_TOO_LARGE,
3298            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3299        )
3300            .into_response();
3301    }
3302
3303    // Sanitise: strip any directory component from the filename.
3304    let filename = std::path::Path::new(&body.filename)
3305        .file_name()
3306        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3307
3308    let upload_id = uuid::Uuid::new_v4();
3309    let staging = std::env::temp_dir()
3310        .join("oxide-sloc-uploads")
3311        .join(upload_id.to_string());
3312
3313    if tokio::fs::create_dir_all(&staging).await.is_err() {
3314        return (
3315            StatusCode::INTERNAL_SERVER_ERROR,
3316            Json(serde_json::json!({"error": "Failed to create staging directory"})),
3317        )
3318            .into_response();
3319    }
3320
3321    let dest = staging.join(&filename);
3322    if tokio::fs::write(&dest, &data).await.is_err() {
3323        let _ = tokio::fs::remove_dir_all(&staging).await;
3324        return (
3325            StatusCode::INTERNAL_SERVER_ERROR,
3326            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3327        )
3328            .into_response();
3329    }
3330
3331    Json(serde_json::json!({
3332        "tmp_path": dest.to_string_lossy(),
3333        "upload_id": upload_id.to_string()
3334    }))
3335    .into_response()
3336}
3337
3338/// POST /api/upload-tarball
3339///
3340/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
3341/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
3342/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
3343/// project root. The two size fields power the "Original / Compressed project size" display in the
3344/// web UI.
3345///
3346/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
3347/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
3348/// cap during decompression. The browser-side JS creates the archive one file at a time using
3349/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
3350/// project size.
3351/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
3352/// decompressed. Wraps any `std::io::Read` source.
3353struct SizeLimitReader<R> {
3354    inner: R,
3355    remaining: u64,
3356}
3357impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3358    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3359        if self.remaining == 0 {
3360            return Err(std::io::Error::other("decompressed size limit exceeded"));
3361        }
3362        let n = self.inner.read(buf)?;
3363        self.remaining = self.remaining.saturating_sub(n as u64);
3364        Ok(n)
3365    }
3366}
3367
3368async fn upload_tarball_handler(
3369    State(state): State<AppState>,
3370    request: axum::extract::Request,
3371) -> Response {
3372    if !state.server_mode {
3373        return StatusCode::NOT_FOUND.into_response();
3374    }
3375
3376    let upload_id = uuid::Uuid::new_v4().to_string();
3377    let upload_base = upload_base_dir();
3378    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3379    let staging = upload_staging_path(&upload_id);
3380    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3381
3382    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3383        tracing::error!(
3384            event = "upload_io_error",
3385            "failed to create upload base dir: {e}"
3386        );
3387        return (
3388            StatusCode::INTERNAL_SERVER_ERROR,
3389            Json(serde_json::json!({"error": "Upload initialization failed"})),
3390        )
3391            .into_response();
3392    }
3393
3394    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
3395    let compressed_bytes =
3396        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3397            Ok(n) => n,
3398            Err(resp) => return resp,
3399        };
3400
3401    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
3402    if let Err(resp) =
3403        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3404    {
3405        return resp;
3406    }
3407
3408    // ── 3. Find the project root inside the staging dir ───────────────────────
3409    // If the tar contained a single top-level directory (the common case when the
3410    // browser uses `webkitRelativePath`), return that as the scan root so the path
3411    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3412    let scan_root = find_single_top_dir(&staging)
3413        .await
3414        .unwrap_or_else(|| staging.clone());
3415
3416    // Compute original (uncompressed) size of the extracted tree.
3417    let original_bytes = tokio::task::spawn_blocking({
3418        let p = scan_root.clone();
3419        move || dir_size_bytes(&p)
3420    })
3421    .await
3422    .unwrap_or(0);
3423
3424    Json(serde_json::json!({
3425        "tmp_path": scan_root.to_string_lossy(),
3426        "upload_id": upload_id,
3427        "compressed_bytes": compressed_bytes,
3428        "original_bytes": original_bytes,
3429    }))
3430    .into_response()
3431}
3432
3433#[derive(Deserialize)]
3434struct LocateReportForm {
3435    file_path: String,
3436    #[serde(default)]
3437    redirect_url: Option<String>,
3438    #[serde(default)]
3439    expected_run_id: Option<String>,
3440}
3441
3442/// Render a view-reports error page and return it as a `Response`.
3443fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3444    let html = ErrorTemplate {
3445        message: message.into(),
3446        last_report_url: Some("/view-reports".to_string()),
3447        last_report_label: Some("View Reports".to_string()),
3448        run_id: None,
3449        error_code: None,
3450        csp_nonce: csp_nonce.to_owned(),
3451        version: env!("CARGO_PKG_VERSION"),
3452    }
3453    .render()
3454    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3455    Html(html).into_response()
3456}
3457
3458/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3459fn registry_entry_from_run(
3460    run: &AnalysisRun,
3461    json_path: PathBuf,
3462    html_path: PathBuf,
3463) -> RegistryEntry {
3464    let project_label = run.input_roots.first().map_or_else(
3465        || "Unknown Project".to_string(),
3466        |r| sanitize_project_label(r),
3467    );
3468    RegistryEntry {
3469        run_id: run.tool.run_id.clone(),
3470        timestamp_utc: run.tool.timestamp_utc,
3471        project_label,
3472        input_roots: run.input_roots.clone(),
3473        json_path: Some(json_path),
3474        html_path: Some(html_path),
3475        pdf_path: None,
3476        summary: ScanSummarySnapshot::from(&run.summary_totals),
3477        csv_path: None,
3478        xlsx_path: None,
3479        git_branch: None,
3480        git_commit: None,
3481        git_commit_long: None,
3482        git_author: None,
3483        git_tags: None,
3484        git_nearest_tag: None,
3485        git_commit_date: None,
3486    }
3487}
3488
3489/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3490/// immediately without requiring a server restart.
3491pub(crate) async fn register_artifacts_in_registry(
3492    state: &AppState,
3493    label: &str,
3494    run: &AnalysisRun,
3495    artifacts: &RunArtifacts,
3496) {
3497    let Some(json_path) = artifacts.json_path.clone() else {
3498        return;
3499    };
3500    let Some(html_path) = artifacts.html_path.clone() else {
3501        return;
3502    };
3503    let mut entry = registry_entry_from_run(run, json_path, html_path);
3504    entry.project_label = label.to_owned();
3505    let mut reg = state.registry.lock().await;
3506    reg.add_entry(entry);
3507    let _ = reg.save(&state.registry_path);
3508}
3509
3510fn is_html_report_file(p: &Path) -> bool {
3511    p.is_file()
3512        && p.extension()
3513            .and_then(|x| x.to_str())
3514            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3515        && p.file_name()
3516            .and_then(|n| n.to_str())
3517            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3518}
3519
3520fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3521    fs::read_dir(dir)
3522        .ok()?
3523        .flatten()
3524        .map(|e| e.path())
3525        .find(|p| is_html_report_file(p))
3526}
3527
3528fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3529    if let Some(f) = find_html_report_in_dir(dir) {
3530        return Some(f);
3531    }
3532    if let Ok(rd) = fs::read_dir(dir) {
3533        for entry in rd.flatten() {
3534            let sub = entry.path();
3535            if sub.is_dir() {
3536                if let Some(f) = find_html_report_in_dir(&sub) {
3537                    return Some(f);
3538                }
3539            }
3540        }
3541    }
3542    None
3543}
3544
3545/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3546/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3547///
3548/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3549#[allow(clippy::result_large_err)]
3550fn validate_locate_request(
3551    state: &AppState,
3552    file_path: &str,
3553    csp_nonce: &str,
3554) -> Result<(PathBuf, PathBuf), Response> {
3555    let raw = PathBuf::from(file_path);
3556
3557    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3558    let html_path = if raw.is_dir() {
3559        let found = find_html_report_in_tree(&raw);
3560        match found {
3561            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3562            None => {
3563                return Err(locate_report_error(
3564                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3565                     the folder that contains your scan output (result_*.html or report_*.html).",
3566                    csp_nonce,
3567                ));
3568            }
3569        }
3570    } else {
3571        let file_ext = raw
3572            .extension()
3573            .and_then(|e| e.to_str())
3574            .unwrap_or("")
3575            .to_ascii_lowercase();
3576        if file_ext != "html" {
3577            return Err(locate_report_error(
3578                "Please select the scan output folder, or an .html report file directly.",
3579                csp_nonce,
3580            ));
3581        }
3582        match fs::canonicalize(&raw) {
3583            Ok(p) => strip_unc_prefix(p),
3584            Err(_) => {
3585                return Err(locate_report_error(
3586                    "Report file not found or path is invalid.",
3587                    csp_nonce,
3588                ));
3589            }
3590        }
3591    };
3592
3593    if state.server_mode {
3594        let output_root = resolve_output_root(None);
3595        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3596        if !html_path.starts_with(&canonical_root) {
3597            return Err(locate_report_error(
3598                "Report file must be within the configured output directory.",
3599                csp_nonce,
3600            ));
3601        }
3602    }
3603    let parent = match html_path.parent() {
3604        Some(p) => p.to_path_buf(),
3605        None => {
3606            return Err(locate_report_error(
3607                "Report file has no parent directory.",
3608                csp_nonce,
3609            ));
3610        }
3611    };
3612    Ok((html_path, parent))
3613}
3614
3615/// JSON-or-HTML error for `locate_report_handler` error paths.
3616fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3617    if want_json {
3618        (
3619            StatusCode::UNPROCESSABLE_ENTITY,
3620            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3621        )
3622            .into_response()
3623    } else {
3624        locate_report_error(msg, csp_nonce)
3625    }
3626}
3627
3628/// JSON-or-redirect success for locate/relocate handler success paths.
3629fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3630    if want_json {
3631        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3632    } else {
3633        axum::response::Redirect::to(redirect).into_response()
3634    }
3635}
3636
3637/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3638/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3639fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3640    for jpath in candidates {
3641        if let Ok(run) = read_json(jpath) {
3642            if expected.is_empty() || run.tool.run_id == expected {
3643                return Some((jpath.clone(), run.tool.run_id));
3644            }
3645        }
3646    }
3647    None
3648}
3649
3650fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3651    html_path
3652        .parent()
3653        .and_then(|p| p.parent())
3654        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3655}
3656
3657fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3658    let mut hits = collect_result_json_candidates(scan_root);
3659    if hits.is_empty() {
3660        hits = collect_result_json_candidates(parent);
3661    }
3662    hits.sort();
3663    hits
3664}
3665
3666#[allow(clippy::too_many_lines)]
3667async fn locate_report_handler(
3668    State(state): State<AppState>,
3669    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3670    headers: axum::http::HeaderMap,
3671    Form(form): Form<LocateReportForm>,
3672) -> impl IntoResponse {
3673    let want_json = headers
3674        .get(axum::http::header::ACCEPT)
3675        .and_then(|v| v.to_str().ok())
3676        .is_some_and(|v| v.contains("application/json"));
3677
3678    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3679        Ok(v) => v,
3680        Err(resp) => {
3681            if want_json {
3682                return locate_handler_err(
3683                    true,
3684                    "No HTML report file found in the selected folder. \
3685                     Make sure you selected the folder that contains your \
3686                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3687                        .to_string(),
3688                    &csp_nonce,
3689                );
3690            }
3691            return resp;
3692        }
3693    };
3694
3695    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3696    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3697    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3698    let scan_root: &Path = &scan_root_owned;
3699    let json_candidates = gather_json_candidates(scan_root, &parent);
3700
3701    // If the expected_run_id was provided, find a JSON that matches it exactly.
3702    let expected_run_id = form
3703        .expected_run_id
3704        .as_deref()
3705        .unwrap_or("")
3706        .trim()
3707        .to_string();
3708
3709    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3710
3711    // If we have candidates but none matched the expected run_id, surface a clear error.
3712    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3713        let actual = json_candidates
3714            .iter()
3715            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3716            .unwrap_or_else(|| "unknown".to_string());
3717        return locate_handler_err(
3718            want_json,
3719            format!(
3720                "This folder contains a different scan.\n\n\
3721                 Expected run ID : {expected_run_id}\n\
3722                 Found run ID    : {actual}\n\n\
3723                 Please select the folder that contains the correct scan output."
3724            ),
3725            &csp_nonce,
3726        );
3727    }
3728
3729    let safe_redirect = form
3730        .redirect_url
3731        .as_deref()
3732        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3733        .unwrap_or("/view-reports?linked=1")
3734        .to_string();
3735
3736    let mut reg = state.registry.lock().await;
3737
3738    if let Some((json_path, run_id)) = matched_json {
3739        // Match by run_id in the registry (works even after files are moved).
3740        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3741            entry.html_path = Some(html_path);
3742            entry.json_path = Some(json_path);
3743            let _ = reg.save(&state.registry_path);
3744            drop(reg);
3745            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3746            state.artifacts.lock().await.remove(&run_id);
3747            return redirect_or_json_ok(want_json, &safe_redirect);
3748        }
3749        // No existing entry — build one from the JSON.
3750        match read_json(&json_path) {
3751            Ok(run) => {
3752                let entry = registry_entry_from_run(&run, json_path, html_path);
3753                reg.add_entry(entry);
3754                let _ = reg.save(&state.registry_path);
3755                drop(reg);
3756                state.artifacts.lock().await.remove(&run_id);
3757                return redirect_or_json_ok(want_json, &safe_redirect);
3758            }
3759            Err(e) => {
3760                drop(reg);
3761                return locate_handler_err(
3762                    want_json,
3763                    format!(
3764                        "Found the scan folder but could not parse the result JSON.\n\n\
3765                         The file may have been saved by an older version of OxideSLOC. \
3766                         Re-running the analysis will create a fresh, compatible record.\n\n\
3767                         Error: {e}"
3768                    ),
3769                    &csp_nonce,
3770                );
3771            }
3772        }
3773    }
3774
3775    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
3776    if let Some(entry) = reg
3777        .entries
3778        .iter_mut()
3779        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3780    {
3781        entry.html_path = Some(html_path.clone());
3782        let _ = reg.save(&state.registry_path);
3783        drop(reg);
3784        state.artifacts.lock().await.remove(&expected_run_id);
3785        return redirect_or_json_ok(want_json, &safe_redirect);
3786    }
3787
3788    drop(reg);
3789    let hint = if state.server_mode {
3790        String::new()
3791    } else {
3792        format!(
3793            "\n\nSearched folder : {}\nHTML found      : {}",
3794            scan_root.display(),
3795            html_path.display()
3796        )
3797    };
3798    locate_handler_err(
3799        want_json,
3800        format!(
3801            "Could not link this report.\n\n\
3802             No result_*.json was found in the selected folder. \
3803             Make sure you selected the top-level scan output folder \
3804             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3805        ),
3806        &csp_nonce,
3807    )
3808}
3809
3810/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3811fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3812    fs::read_dir(dir)
3813        .ok()?
3814        .flatten()
3815        .map(|e| e.path())
3816        .find(|p| {
3817            p.is_file()
3818                && p.file_stem()
3819                    .and_then(|n| n.to_str())
3820                    .is_some_and(|n| n.starts_with("result"))
3821                && p.extension()
3822                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3823        })
3824}
3825
3826#[derive(Deserialize)]
3827struct LocateReportsDirForm {
3828    folder_path: String,
3829}
3830
3831#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3832async fn locate_reports_dir_handler(
3833    State(state): State<AppState>,
3834    Form(form): Form<LocateReportsDirForm>,
3835) -> impl IntoResponse {
3836    if state.server_mode {
3837        return StatusCode::NOT_FOUND.into_response();
3838    }
3839    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3840        Ok(p) => strip_unc_prefix(p),
3841        Err(_) => {
3842            return axum::response::Redirect::to(
3843                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3844            )
3845            .into_response();
3846        }
3847    };
3848    if !folder.is_dir() {
3849        return axum::response::Redirect::to(
3850            "/view-reports?error=Selected+path+is+not+a+directory.",
3851        )
3852        .into_response();
3853    }
3854
3855    let candidates = collect_result_json_candidates(&folder);
3856
3857    if candidates.is_empty() {
3858        return axum::response::Redirect::to(
3859            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3860        )
3861        .into_response();
3862    }
3863
3864    let mut linked_count: usize = 0;
3865    let mut reg = state.registry.lock().await;
3866    for json_path in candidates {
3867        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3868            continue;
3869        };
3870        if is_dir_already_registered(&reg, &parent) {
3871            continue;
3872        }
3873        let Some(entry) = build_registry_entry_from_json(json_path) else {
3874            continue;
3875        };
3876        reg.add_entry(entry);
3877        linked_count += 1;
3878    }
3879    let _ = reg.save(&state.registry_path);
3880    drop(reg);
3881
3882    if linked_count == 0 {
3883        return axum::response::Redirect::to(
3884            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3885        )
3886        .into_response();
3887    }
3888    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3889}
3890
3891#[derive(Deserialize)]
3892struct RelocateScanForm {
3893    run_id: String,
3894    folder_path: String,
3895    redirect_url: String,
3896}
3897
3898/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3899/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3900fn relocate_folder_err(
3901    want_json: bool,
3902    status: StatusCode,
3903    msg: &str,
3904    run_id: &str,
3905    folder_hint: &str,
3906    redirect_url: &str,
3907    csp_nonce: &str,
3908) -> Response {
3909    if want_json {
3910        (
3911            status,
3912            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3913        )
3914            .into_response()
3915    } else {
3916        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3917    }
3918}
3919
3920#[allow(clippy::too_many_lines)]
3921async fn relocate_scan_handler(
3922    State(state): State<AppState>,
3923    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3924    headers: axum::http::HeaderMap,
3925    Form(form): Form<RelocateScanForm>,
3926) -> impl IntoResponse {
3927    let want_json = headers
3928        .get(axum::http::header::ACCEPT)
3929        .and_then(|v| v.to_str().ok())
3930        .is_some_and(|v| v.contains("application/json"));
3931    if state.server_mode {
3932        return StatusCode::NOT_FOUND.into_response();
3933    }
3934
3935    let run_id = form.run_id.trim().to_string();
3936    let redirect_url = form.redirect_url.trim().to_string();
3937
3938    let run_exists = {
3939        let reg = state.registry.lock().await;
3940        reg.find_by_run_id(&run_id).is_some()
3941    };
3942    if !run_exists {
3943        if want_json {
3944            return (
3945                StatusCode::NOT_FOUND,
3946                axum::Json(serde_json::json!({
3947                    "ok": false,
3948                    "message": format!("Run ID '{run_id}' not found in registry.")
3949                })),
3950            )
3951                .into_response();
3952        }
3953        let html = ErrorTemplate {
3954            message: format!("Run ID '{run_id}' not found in registry."),
3955            last_report_url: Some("/compare-scans".to_string()),
3956            last_report_label: Some("Compare Scans".to_string()),
3957            run_id: Some(run_id.clone()),
3958            error_code: Some(404),
3959            csp_nonce: csp_nonce.clone(),
3960            version: env!("CARGO_PKG_VERSION"),
3961        }
3962        .render()
3963        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3964        return Html(html).into_response();
3965    }
3966
3967    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3968        Ok(p) => strip_unc_prefix(p),
3969        Err(_) => {
3970            return relocate_folder_err(
3971                want_json,
3972                StatusCode::UNPROCESSABLE_ENTITY,
3973                "Folder not found or path is invalid.",
3974                &run_id,
3975                form.folder_path.trim(),
3976                &redirect_url,
3977                &csp_nonce,
3978            );
3979        }
3980    };
3981    if !folder.is_dir() {
3982        return relocate_folder_err(
3983            want_json,
3984            StatusCode::UNPROCESSABLE_ENTITY,
3985            "Selected path is not a directory.",
3986            &run_id,
3987            &folder.display().to_string(),
3988            &redirect_url,
3989            &csp_nonce,
3990        );
3991    }
3992
3993    let json_candidates = find_result_files_by_ext(&folder, "json");
3994    if json_candidates.is_empty() {
3995        let msg = format!(
3996            "No result JSON files found in the selected folder.\nSearched: {}",
3997            folder.display()
3998        );
3999        return relocate_folder_err(
4000            want_json,
4001            StatusCode::UNPROCESSABLE_ENTITY,
4002            &msg,
4003            &run_id,
4004            &folder.display().to_string(),
4005            &redirect_url,
4006            &csp_nonce,
4007        );
4008    }
4009
4010    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4011        let msg = format!(
4012            "No matching scan found in the selected folder.\n\
4013             The JSON files present do not contain run ID: {run_id}\n\
4014             Searched: {}",
4015            folder.display()
4016        );
4017        return relocate_folder_err(
4018            want_json,
4019            StatusCode::UNPROCESSABLE_ENTITY,
4020            &msg,
4021            &run_id,
4022            &folder.display().to_string(),
4023            &redirect_url,
4024            &csp_nonce,
4025        );
4026    };
4027
4028    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4029    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4030    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4031
4032    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4033        redirect_url
4034    } else {
4035        "/compare-scans".to_string()
4036    };
4037    redirect_or_json_ok(want_json, &safe_redirect)
4038}
4039
4040fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4041    let mut out = Vec::new();
4042    collect_scan_files_by_ext(folder, ext, &mut out);
4043    if let Ok(rd) = fs::read_dir(folder) {
4044        for entry in rd.flatten() {
4045            let sub = entry.path();
4046            if sub.is_dir() {
4047                collect_scan_files_by_ext(&sub, ext, &mut out);
4048            }
4049        }
4050    }
4051    out
4052}
4053
4054fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4055    let Ok(rd) = fs::read_dir(dir) else { return };
4056    for entry in rd.flatten() {
4057        let p = entry.path();
4058        if p.is_file()
4059            && p.file_stem()
4060                .and_then(|n| n.to_str())
4061                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4062            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4063        {
4064            out.push(p);
4065        }
4066    }
4067}
4068
4069fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4070    candidates
4071        .iter()
4072        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4073        .cloned()
4074}
4075
4076/// Return the best folder hint for the relocate page.
4077/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
4078/// point at the parent — the actual top-level output directory — so the user
4079/// selects the root folder rather than the subfolder.
4080fn output_folder_hint(json_path: &std::path::Path) -> String {
4081    let Some(direct_parent) = json_path.parent() else {
4082        return String::new();
4083    };
4084    let parent_name = direct_parent
4085        .file_name()
4086        .and_then(|n| n.to_str())
4087        .unwrap_or("");
4088    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4089        direct_parent.parent().map_or_else(
4090            || direct_parent.display().to_string(),
4091            |p| p.display().to_string(),
4092        )
4093    } else {
4094        direct_parent.display().to_string()
4095    }
4096}
4097
4098async fn update_run_file_paths(
4099    state: &AppState,
4100    run_id: &str,
4101    json_path: PathBuf,
4102    html_path: Option<PathBuf>,
4103    pdf_path: Option<PathBuf>,
4104) {
4105    {
4106        let mut reg = state.registry.lock().await;
4107        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4108            entry.json_path = Some(json_path.clone());
4109            if let Some(ref hp) = html_path {
4110                entry.html_path = Some(hp.clone());
4111            }
4112            if let Some(ref pp) = pdf_path {
4113                entry.pdf_path = Some(pp.clone());
4114            }
4115        }
4116        let _ = reg.save(&state.registry_path);
4117    }
4118    // Also patch the in-memory artifacts map so the result page picks up the
4119    // new paths without requiring a server restart.
4120    {
4121        let mut map = state.artifacts.lock().await;
4122        if let Some(arts) = map.get_mut(run_id) {
4123            arts.json_path = Some(json_path);
4124            if let Some(hp) = html_path {
4125                arts.html_path = Some(hp);
4126            }
4127            if let Some(pp) = pdf_path {
4128                arts.pdf_path = Some(pp);
4129            }
4130        }
4131    }
4132}
4133
4134fn missing_scan_relocate_response(
4135    message: &str,
4136    run_id: &str,
4137    folder_hint: &str,
4138    redirect_url: &str,
4139    server_mode: bool,
4140    csp_nonce: &str,
4141) -> axum::response::Response {
4142    let html = RelocateScanTemplate {
4143        message: message.to_string(),
4144        run_id: run_id.to_string(),
4145        folder_hint: folder_hint.to_string(),
4146        redirect_url: redirect_url.to_string(),
4147        server_mode,
4148        csp_nonce: csp_nonce.to_owned(),
4149        version: env!("CARGO_PKG_VERSION"),
4150    }
4151    .render()
4152    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4153    (StatusCode::NOT_FOUND, Html(html)).into_response()
4154}
4155
4156// ── Watched-directory helpers ─────────────────────────────────────────────────
4157
4158/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
4159fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4160    fs::read_dir(dir)
4161        .ok()?
4162        .flatten()
4163        .map(|e| e.path())
4164        .find(|p| {
4165            p.is_file()
4166                && p.extension()
4167                    .and_then(|e| e.to_str())
4168                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4169        })
4170}
4171
4172/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
4173/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
4174/// (`<scan_dir>/json/result*.json`).
4175fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4176    let mut out = Vec::new();
4177    if let Some(j) = find_result_json_in_dir(sub) {
4178        out.push(j);
4179    }
4180    let json_sub = sub.join("json");
4181    if json_sub.is_dir() {
4182        if let Some(j) = find_result_json_in_dir(&json_sub) {
4183            out.push(j);
4184        }
4185    }
4186    out
4187}
4188
4189fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4190    let mut candidates = Vec::new();
4191    if let Some(j) = find_result_json_in_dir(folder) {
4192        candidates.push(j);
4193    }
4194    let Ok(dir_entries) = fs::read_dir(folder) else {
4195        return candidates;
4196    };
4197    for entry in dir_entries.flatten() {
4198        let sub = entry.path();
4199        if sub.is_dir() {
4200            candidates.extend(subdir_result_json_candidates(&sub));
4201        }
4202    }
4203    candidates
4204}
4205
4206fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4207    reg.entries.iter().any(|e| {
4208        let dir_match = e
4209            .json_path
4210            .as_ref()
4211            .and_then(|p| p.parent())
4212            .is_some_and(|p| p == parent)
4213            || e.html_path
4214                .as_ref()
4215                .and_then(|p| p.parent())
4216                .is_some_and(|p| p == parent);
4217        dir_match
4218            && (e.json_path.as_ref().is_some_and(|p| p.exists())
4219                || e.html_path.as_ref().is_some_and(|p| p.exists()))
4220    })
4221}
4222
4223fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4224    let json_dir = json_path.parent()?.to_path_buf();
4225    // If the JSON lives inside a directory named "json", the scan root is its parent
4226    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
4227    let (html_path, pdf_path, csv_path, xlsx_path) =
4228        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4229            let scan_root = json_dir.parent()?;
4230            let html = find_html_report_in_dir(&scan_root.join("html"))
4231                .or_else(|| find_html_report_in_dir(scan_root));
4232            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4233            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4234            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4235            (html, pdf, csv, xlsx)
4236        } else {
4237            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4238                rd.flatten()
4239                    .map(|e| e.path())
4240                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4241            });
4242            (html, None, None, None)
4243        };
4244    let run = read_json(&json_path).ok()?;
4245    let project_label = run.input_roots.first().map_or_else(
4246        || "Unknown Project".to_string(),
4247        |r| sanitize_project_label(r),
4248    );
4249    Some(RegistryEntry {
4250        run_id: run.tool.run_id.clone(),
4251        timestamp_utc: run.tool.timestamp_utc,
4252        project_label,
4253        input_roots: run.input_roots.clone(),
4254        json_path: Some(json_path),
4255        html_path,
4256        pdf_path,
4257        csv_path,
4258        xlsx_path,
4259        summary: ScanSummarySnapshot::from(&run.summary_totals),
4260        git_branch: run.git_branch.clone(),
4261        git_commit: run.git_commit_short.clone(),
4262        git_commit_long: run.git_commit_long.clone(),
4263        git_author: run.git_commit_author.clone(),
4264        git_tags: run.git_tags.clone(),
4265        git_nearest_tag: run.git_nearest_tag.clone(),
4266        git_commit_date: run.git_commit_date,
4267    })
4268}
4269
4270/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
4271/// Returns the number of newly linked entries.
4272fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4273    let mut linked = 0usize;
4274    for json_path in collect_result_json_candidates(folder) {
4275        let Some(parent) = json_path.parent().map(PathBuf::from) else {
4276            continue;
4277        };
4278        if is_dir_already_registered(reg, &parent) {
4279            continue;
4280        }
4281        let Some(entry) = build_registry_entry_from_json(json_path) else {
4282            continue;
4283        };
4284        reg.add_entry(entry);
4285        linked += 1;
4286    }
4287    linked
4288}
4289
4290/// Scan all watched directories (plus the default output root) into `reg`.
4291async fn auto_scan_watched_dirs(state: &AppState) {
4292    let dirs: Vec<PathBuf> = {
4293        let wd = state.watched_dirs.lock().await;
4294        wd.dirs.clone()
4295    };
4296    // Reconcile the registry to the watched-folder model: keep only entries under a
4297    // currently-watched folder or the app's own output directory. This drops leftovers from
4298    // folders that have since been un-watched (which would otherwise linger in the list).
4299    {
4300        let output_root = resolve_output_root(None);
4301        let mut roots: Vec<PathBuf> = dirs.clone();
4302        if let Ok(canon) = fs::canonicalize(&output_root) {
4303            roots.push(strip_unc_prefix(canon));
4304        }
4305        roots.push(output_root);
4306        let mut reg = state.registry.lock().await;
4307        if reg.retain_under_roots(&roots) > 0 {
4308            let _ = reg.save(&state.registry_path);
4309        }
4310    }
4311    if dirs.is_empty() {
4312        return;
4313    }
4314    let mut reg = state.registry.lock().await;
4315    let mut total = 0usize;
4316    for dir in &dirs {
4317        if dir.is_dir() {
4318            total += scan_folder_into_registry(dir, &mut reg);
4319        }
4320    }
4321    if total > 0 {
4322        let _ = reg.save(&state.registry_path);
4323    }
4324}
4325
4326// ── Watched-dir route forms ───────────────────────────────────────────────────
4327
4328#[derive(Deserialize)]
4329struct WatchedDirForm {
4330    folder_path: String,
4331    #[serde(default = "default_redirect")]
4332    redirect_to: String,
4333}
4334
4335fn default_redirect() -> String {
4336    "/view-reports".to_string()
4337}
4338
4339#[derive(Deserialize)]
4340struct WatchedDirRefreshForm {
4341    #[serde(default = "default_redirect")]
4342    redirect_to: String,
4343}
4344
4345// ── Watched-dir helpers ───────────────────────────────────────────────────────
4346
4347/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
4348fn safe_redirect(dest: &str) -> &str {
4349    if dest.starts_with('/') {
4350        dest
4351    } else {
4352        "/"
4353    }
4354}
4355
4356// ── Watched-dir handlers ──────────────────────────────────────────────────────
4357
4358async fn add_watched_dir_handler(
4359    State(state): State<AppState>,
4360    Form(form): Form<WatchedDirForm>,
4361) -> impl IntoResponse {
4362    if state.server_mode {
4363        return StatusCode::NOT_FOUND.into_response();
4364    }
4365    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4366        strip_unc_prefix(p)
4367    } else {
4368        let dest = format!(
4369            "{}?error=Folder+not+found+or+path+is+invalid.",
4370            safe_redirect(&form.redirect_to)
4371        );
4372        return axum::response::Redirect::to(&dest).into_response();
4373    };
4374    if !folder.is_dir() {
4375        let dest = format!(
4376            "{}?error=Selected+path+is+not+a+directory.",
4377            safe_redirect(&form.redirect_to)
4378        );
4379        return axum::response::Redirect::to(&dest).into_response();
4380    }
4381
4382    // Persist the watched directory.
4383    {
4384        let mut wd = state.watched_dirs.lock().await;
4385        wd.add(folder.clone());
4386        let _ = wd.save(&state.watched_dirs_path);
4387    }
4388
4389    // Immediately scan the folder and add any new reports.
4390    let linked = {
4391        let mut reg = state.registry.lock().await;
4392        let n = scan_folder_into_registry(&folder, &mut reg);
4393        if n > 0 {
4394            let _ = reg.save(&state.registry_path);
4395        }
4396        n
4397    };
4398
4399    let dest = if linked > 0 {
4400        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4401    } else {
4402        format!(
4403            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4404            safe_redirect(&form.redirect_to)
4405        )
4406    };
4407    axum::response::Redirect::to(&dest).into_response()
4408}
4409
4410async fn remove_watched_dir_handler(
4411    State(state): State<AppState>,
4412    Form(form): Form<WatchedDirForm>,
4413) -> impl IntoResponse {
4414    if state.server_mode {
4415        return StatusCode::NOT_FOUND.into_response();
4416    }
4417    let folder = PathBuf::from(&form.folder_path);
4418    {
4419        let mut wd = state.watched_dirs.lock().await;
4420        wd.remove(&folder);
4421        let _ = wd.save(&state.watched_dirs_path);
4422    }
4423    // Drop any reports that were linked in from this folder so the list reflects the removal.
4424    {
4425        let mut reg = state.registry.lock().await;
4426        if reg.remove_entries_under(&folder) > 0 {
4427            let _ = reg.save(&state.registry_path);
4428        }
4429    }
4430    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4431}
4432
4433async fn refresh_watched_dirs_handler(
4434    State(state): State<AppState>,
4435    Form(form): Form<WatchedDirRefreshForm>,
4436) -> impl IntoResponse {
4437    if state.server_mode {
4438        return StatusCode::NOT_FOUND.into_response();
4439    }
4440    let dirs: Vec<PathBuf> = {
4441        let wd = state.watched_dirs.lock().await;
4442        wd.dirs.clone()
4443    };
4444    let mut total = 0usize;
4445    {
4446        let mut reg = state.registry.lock().await;
4447        reg.prune_stale();
4448        for dir in &dirs {
4449            if dir.is_dir() {
4450                total += scan_folder_into_registry(dir, &mut reg);
4451            }
4452        }
4453        let _ = reg.save(&state.registry_path);
4454    }
4455    let dest = if total > 0 {
4456        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4457    } else {
4458        safe_redirect(&form.redirect_to).to_owned()
4459    };
4460    axum::response::Redirect::to(&dest).into_response()
4461}
4462
4463#[derive(Debug, Deserialize)]
4464struct OpenPathQuery {
4465    path: Option<String>,
4466}
4467
4468fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4469    let mut ancestor = std::path::Path::new(raw);
4470    loop {
4471        match ancestor.parent() {
4472            Some(p) => {
4473                ancestor = p;
4474                if ancestor.is_dir() {
4475                    break;
4476                }
4477            }
4478            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4479        }
4480    }
4481    Ok(ancestor.to_path_buf())
4482}
4483
4484async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4485    match tokio::fs::canonicalize(raw).await {
4486        Ok(canonical) if canonical.is_file() => canonical
4487            .parent()
4488            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4489                Ok(p.to_path_buf())
4490            }),
4491        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4492        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4493        Err(_) => find_existing_ancestor(raw),
4494    }
4495}
4496
4497async fn open_path_handler(
4498    State(state): State<AppState>,
4499    Query(query): Query<OpenPathQuery>,
4500) -> impl IntoResponse {
4501    if state.server_mode {
4502        return Json(serde_json::json!({
4503            "server_mode_disabled": true,
4504            "message": "Opening a path in the file manager is only available in local desktop mode."
4505        }))
4506        .into_response();
4507    }
4508    // Skip the OS file-manager call in headless / CI environments.
4509    if std::env::var("SLOC_HEADLESS").is_ok() {
4510        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4511    }
4512    let raw = match query.path.as_deref() {
4513        Some(p) if !p.is_empty() => p,
4514        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4515    };
4516
4517    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4518    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4519    // so the file explorer still opens somewhere useful.
4520    let target = match resolve_open_target(raw).await {
4521        Ok(p) => p,
4522        Err((code, msg)) => return (code, msg).into_response(),
4523    };
4524
4525    #[cfg(target_os = "windows")]
4526    win_dialog_focus::open_folder_foreground(target);
4527    #[cfg(target_os = "macos")]
4528    let _ = std::process::Command::new("open")
4529        .arg(&target)
4530        .stdout(Stdio::null())
4531        .stderr(Stdio::null())
4532        .spawn();
4533    #[cfg(target_os = "linux")]
4534    {
4535        let folder_name = target
4536            .file_name()
4537            .and_then(|n| n.to_str())
4538            .map(str::to_owned);
4539        let _ = std::process::Command::new("xdg-open")
4540            .arg(&target)
4541            .stdout(Stdio::null())
4542            .stderr(Stdio::null())
4543            .spawn();
4544        // Best-effort: raise the file manager window once it appears.
4545        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4546        // installed; failures are silently discarded.
4547        if let Some(name) = folder_name {
4548            std::thread::spawn(move || {
4549                std::thread::sleep(std::time::Duration::from_millis(800));
4550                let _ = std::process::Command::new("wmctrl")
4551                    .args(["-a", &name])
4552                    .stdout(Stdio::null())
4553                    .stderr(Stdio::null())
4554                    .spawn();
4555            });
4556        }
4557    }
4558
4559    Json(serde_json::json!({"ok": true})).into_response()
4560}
4561
4562async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4563    let (content_type, bytes): (&'static str, &'static [u8]) =
4564        match (folder.as_str(), file.as_str()) {
4565            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4566            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4567            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4568            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4569            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4570            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4571            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4572            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4573            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4574            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4575            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4576            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4577            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4578            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4579            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4580            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4581            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4582            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4583            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4584            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4585            _ => return StatusCode::NOT_FOUND.into_response(),
4586        };
4587    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4588}
4589
4590/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4591/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4592/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4593/// complexity low; the fail-closed semantics are unchanged.
4594fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4595    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4596    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4597    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4598    // sample/upload locations are permitted; everything else is rejected.
4599    let Ok(canonical) = fs::canonicalize(resolved) else {
4600        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4601            return Err(Html(
4602                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4603            ));
4604        }
4605        return Ok(());
4606    };
4607    // Upload temp dirs and built-in sample/fixture paths are always safe.
4608    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4609        return Ok(());
4610    }
4611    let config = &state.base_config;
4612    if config.discovery.allowed_scan_roots.is_empty() {
4613        return Err(Html(
4614            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()
4615        ));
4616    }
4617    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4618        fs::canonicalize(root)
4619            .ok()
4620            .is_some_and(|r| canonical.starts_with(&r))
4621    });
4622    if !allowed {
4623        return Err(Html(
4624            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4625        ));
4626    }
4627    Ok(())
4628}
4629
4630async fn preview_handler(
4631    State(state): State<AppState>,
4632    Query(query): Query<PreviewQuery>,
4633) -> impl IntoResponse {
4634    let raw_path = query
4635        .path
4636        .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4637    let resolved = resolve_input_path(&raw_path);
4638
4639    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4640    // binary whose working directory is not the project root), return a clear message
4641    // instead of an opaque OS error from build_preview_html.
4642    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4643        return Html(
4644            r#"<div class="preview-error">Sample directory not available on this server.
4645            Enter a path to a project directory or upload files using Browse.</div>"#
4646                .to_string(),
4647        );
4648    }
4649
4650    if state.server_mode {
4651        if let Err(resp) = authorize_preview_path(&state, &resolved) {
4652            return resp;
4653        }
4654    }
4655
4656    let include_patterns = split_patterns(query.include_globs.as_deref());
4657    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4658
4659    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4660        Ok(html) => Html(html),
4661        Err(err) => Html(format!(
4662            r#"<div class="preview-error">Preview failed: {}</div>"#,
4663            escape_html(&err.to_string())
4664        )),
4665    }
4666}
4667
4668#[derive(Debug, Deserialize, Default)]
4669struct SuggestCoverageQuery {
4670    path: Option<String>,
4671}
4672
4673#[derive(Serialize)]
4674struct SuggestCoverageResponse {
4675    found: Option<String>,
4676    tool: Option<&'static str>,
4677    hint: Option<&'static str>,
4678}
4679
4680async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4681    const CANDIDATES: &[&str] = &[
4682        // LCOV — cargo-llvm-cov, gcov, lcov
4683        "coverage/lcov.info",
4684        "lcov.info",
4685        "target/llvm-cov/lcov.info",
4686        "target/coverage/lcov.info",
4687        "target/debug/coverage/lcov.info",
4688        "coverage/coverage.lcov",
4689        "build/coverage/lcov.info",
4690        "reports/lcov.info",
4691        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4692        "coverage.xml",
4693        "coverage/coverage.xml",
4694        "target/site/cobertura/coverage.xml",
4695        "build/reports/coverage/coverage.xml",
4696        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4697        "target/site/jacoco/jacoco.xml",
4698        "build/reports/jacoco/test/jacocoTestReport.xml",
4699        "build/reports/jacoco/jacocoTestReport.xml",
4700        "build/jacoco/jacoco.xml",
4701        // coverage.py native JSON — `coverage json`
4702        "coverage.json",
4703        "coverage/coverage.json",
4704    ];
4705    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4706    let found = CANDIDATES
4707        .iter()
4708        .map(|rel| root.join(rel))
4709        .find(|p| p.is_file())
4710        .map(|p| display_path(&p));
4711
4712    let (tool, hint) = detect_coverage_tool(&root);
4713    Json(SuggestCoverageResponse { found, tool, hint })
4714}
4715
4716/// Inspect the project root for known build/package files and return the most likely coverage
4717/// tool name and the shell command needed to generate a coverage file.
4718fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4719    if root.join("Cargo.toml").is_file() {
4720        return (
4721            Some("cargo-llvm-cov"),
4722            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4723        );
4724    }
4725    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4726        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4727    }
4728    if root.join("pom.xml").is_file() {
4729        return (Some("jacoco"), Some("mvn test jacoco:report"));
4730    }
4731    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4732        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4733    }
4734    (None, None)
4735}
4736
4737/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4738#[allow(clippy::result_large_err)]
4739fn validate_server_scan_path(
4740    config: &sloc_config::AppConfig,
4741    resolved_path: &Path,
4742    csp_nonce: &str,
4743) -> Result<(), Response> {
4744    if config.discovery.allowed_scan_roots.is_empty() {
4745        let template = ErrorTemplate {
4746            message: "Scan path rejected: this server has no scan roots configured, so \
4747                      scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4748                      environment variable (colon-separated absolute paths) — or \
4749                      allowed_scan_roots in the config TOML — then restart. Tip: the \
4750                      Browse / directory-upload flow works without this; uploaded folders \
4751                      are scanned from the server's temp area and bypass this check."
4752                .to_string(),
4753            last_report_url: None,
4754            last_report_label: None,
4755            run_id: None,
4756            error_code: Some(403),
4757            csp_nonce: csp_nonce.to_owned(),
4758            version: env!("CARGO_PKG_VERSION"),
4759        };
4760        return Err((
4761            StatusCode::FORBIDDEN,
4762            Html(
4763                template
4764                    .render()
4765                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4766            ),
4767        )
4768            .into_response());
4769    }
4770    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
4771    // location) we must NOT fall back to the raw, un-normalised path — a textual
4772    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
4773    // allowlist. A non-resolvable scan target is rejected outright.
4774    let Ok(canonical) = fs::canonicalize(resolved_path) else {
4775        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4776            "Scan path does not resolve to a real location");
4777        let template = ErrorTemplate {
4778            message: "The requested path could not be resolved to a real directory.".to_string(),
4779            last_report_url: None,
4780            last_report_label: None,
4781            run_id: None,
4782            error_code: Some(403),
4783            csp_nonce: csp_nonce.to_owned(),
4784            version: env!("CARGO_PKG_VERSION"),
4785        };
4786        return Err((
4787            StatusCode::FORBIDDEN,
4788            Html(
4789                template
4790                    .render()
4791                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4792            ),
4793        )
4794            .into_response());
4795    };
4796    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4797        fs::canonicalize(root)
4798            .ok()
4799            .is_some_and(|r| canonical.starts_with(&r))
4800    });
4801    if !allowed {
4802        tracing::warn!(event = "path_rejected", path = %canonical.display(),
4803            "Scan path not in allowed_scan_roots");
4804        let template = ErrorTemplate {
4805            message: "The requested path is not within an allowed scan directory.".to_string(),
4806            last_report_url: None,
4807            last_report_label: None,
4808            run_id: None,
4809            error_code: Some(403),
4810            csp_nonce: csp_nonce.to_owned(),
4811            version: env!("CARGO_PKG_VERSION"),
4812        };
4813        return Err((
4814            StatusCode::FORBIDDEN,
4815            Html(
4816                template
4817                    .render()
4818                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4819            ),
4820        )
4821            .into_response());
4822    }
4823    Ok(())
4824}
4825
4826/// Exclude the output directory from scanning so artifacts don't pollute counts.
4827fn apply_output_dir_exclusions(
4828    config: &mut sloc_config::AppConfig,
4829    project_path: &str,
4830    raw_output_dir: &str,
4831) {
4832    let project_root = resolve_input_path(project_path);
4833    let raw_out = raw_output_dir.trim();
4834    let resolved_out = if raw_out.is_empty() {
4835        project_root.join("sloc")
4836    } else if Path::new(raw_out).is_absolute() {
4837        PathBuf::from(raw_out)
4838    } else {
4839        workspace_root().join(raw_out)
4840    };
4841    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4842        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4843            let dir = first.to_string();
4844            if !config.discovery.excluded_directories.contains(&dir) {
4845                config.discovery.excluded_directories.push(dir);
4846            }
4847        }
4848    }
4849    if !config
4850        .discovery
4851        .excluded_directories
4852        .iter()
4853        .any(|d| d == "sloc")
4854    {
4855        config
4856            .discovery
4857            .excluded_directories
4858            .push("sloc".to_string());
4859    }
4860}
4861
4862/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
4863const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4864    ScanSummarySnapshot {
4865        files_analyzed: run.summary_totals.files_analyzed,
4866        files_skipped: run.summary_totals.files_skipped,
4867        total_physical_lines: run.summary_totals.total_physical_lines,
4868        code_lines: run.summary_totals.code_lines,
4869        comment_lines: run.summary_totals.comment_lines,
4870        blank_lines: run.summary_totals.blank_lines,
4871        functions: run.summary_totals.functions,
4872        classes: run.summary_totals.classes,
4873        variables: run.summary_totals.variables,
4874        imports: run.summary_totals.imports,
4875        test_count: run.summary_totals.test_count,
4876        coverage_lines_found: run.summary_totals.coverage_lines_found,
4877        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4878        coverage_functions_found: run.summary_totals.coverage_functions_found,
4879        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4880        coverage_branches_found: run.summary_totals.coverage_branches_found,
4881        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4882    }
4883}
4884
4885/// Build the `RegistryEntry` for the just-completed scan run.
4886pub(crate) fn build_run_registry_entry(
4887    run: &AnalysisRun,
4888    run_id: &str,
4889    project_label: &str,
4890    artifacts: &RunArtifacts,
4891) -> RegistryEntry {
4892    RegistryEntry {
4893        run_id: run_id.to_owned(),
4894        timestamp_utc: run.tool.timestamp_utc,
4895        project_label: project_label.to_owned(),
4896        input_roots: run.input_roots.clone(),
4897        json_path: artifacts.json_path.clone(),
4898        html_path: artifacts.html_path.clone(),
4899        pdf_path: artifacts.pdf_path.clone(),
4900        csv_path: artifacts.csv_path.clone(),
4901        xlsx_path: artifacts.xlsx_path.clone(),
4902        summary: summary_snapshot_from_run(run),
4903        git_branch: run.git_branch.clone(),
4904        git_commit: run.git_commit_short.clone(),
4905        git_commit_long: run.git_commit_long.clone(),
4906        git_author: run.git_commit_author.clone(),
4907        git_tags: run.git_tags.clone(),
4908        git_nearest_tag: run.git_nearest_tag.clone(),
4909        git_commit_date: run.git_commit_date.clone(),
4910    }
4911}
4912
4913/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
4914fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4915    if let Some(policy) = form.mixed_line_policy {
4916        config.analysis.mixed_line_policy = policy;
4917    }
4918    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4919    config.analysis.generated_file_detection =
4920        form.generated_file_detection.as_deref() != Some("disabled");
4921    config.analysis.minified_file_detection =
4922        form.minified_file_detection.as_deref() != Some("disabled");
4923    config.analysis.vendor_directory_detection =
4924        form.vendor_directory_detection.as_deref() != Some("disabled");
4925    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4926    if let Some(binary_behavior) = form.binary_file_behavior {
4927        config.analysis.binary_file_behavior = binary_behavior;
4928    }
4929    apply_report_opts(config, form);
4930    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4931    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4932    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4933    if let Some(policy) = form.continuation_line_policy {
4934        config.analysis.continuation_line_policy = policy;
4935    }
4936    if let Some(policy) = form.blank_in_block_comment_policy {
4937        config.analysis.blank_in_block_comment_policy = policy;
4938    }
4939    config.analysis.count_compiler_directives =
4940        form.count_compiler_directives.as_deref() != Some("disabled");
4941    apply_style_threshold(config, form);
4942    apply_coverage_path(config, form);
4943}
4944
4945fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4946    if let Some(report_title) = form.report_title.as_deref() {
4947        let trimmed = report_title.trim();
4948        if !trimmed.is_empty() {
4949            config.reporting.report_title = trimmed.to_string();
4950        }
4951    }
4952    if let Some(hf) = form.report_header_footer.as_deref() {
4953        let trimmed = hf.trim();
4954        config.reporting.report_header_footer = if trimmed.is_empty() {
4955            None
4956        } else {
4957            Some(trimmed.to_string())
4958        };
4959    }
4960}
4961
4962fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4963    apply_style_col_threshold(config, form);
4964    apply_style_analysis_enabled(config, form);
4965    apply_style_score_threshold(config, form);
4966    apply_style_lang_scope(config, form);
4967    apply_activity_window(config, form);
4968}
4969
4970fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4971    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4972        if let Ok(t) = threshold_str.parse::<u16>() {
4973            if t == 80 || t == 100 || t == 120 {
4974                config.analysis.style_col_threshold = t;
4975            }
4976        }
4977    }
4978}
4979
4980fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4981    if let Some(v) = form.style_analysis_enabled.as_deref() {
4982        config.analysis.style_analysis_enabled = v != "disabled";
4983    }
4984}
4985
4986fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4987    if let Some(v) = form.style_score_threshold.as_deref() {
4988        if let Ok(t) = v.parse::<u8>() {
4989            config.analysis.style_score_threshold = t.min(100);
4990        }
4991    }
4992}
4993
4994fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4995    if let Some(v) = form.style_lang_scope.as_deref() {
4996        let scope = v.trim();
4997        if scope == "c_family" || scope == "all" {
4998            config.analysis.style_lang_scope = scope.to_string();
4999        }
5000    }
5001}
5002
5003fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5004    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
5005    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
5006    if let Some(w) = form.activity_window.as_deref() {
5007        let w = w.trim();
5008        if !w.is_empty() {
5009            if let Ok(days) = w.parse::<u32>() {
5010                config.analysis.activity_window_days = Some(days);
5011            }
5012        }
5013    }
5014}
5015
5016fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5017    if let Some(cov) = &form.coverage_file {
5018        let trimmed = cov.trim();
5019        if !trimmed.is_empty() {
5020            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5021        }
5022    }
5023}
5024
5025/// Fire-and-forget: generate the PDF in a background task if one is pending.
5026/// On failure, clears `pdf_path` in the artifacts map so the results page shows
5027/// an error instead of spinning indefinitely.
5028fn spawn_pdf_background(
5029    pending_pdf: PendingPdf,
5030    run_id: String,
5031    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5032) {
5033    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5034        tokio::spawn(async move {
5035            let result = tokio::task::spawn_blocking(move || {
5036                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5037                if cleanup_src {
5038                    let _ = fs::remove_file(&pdf_src);
5039                }
5040                r
5041            })
5042            .await;
5043            let failed = match result {
5044                Ok(Ok(())) => false,
5045                Ok(Err(err)) => {
5046                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5047                    true
5048                }
5049                Err(err) => {
5050                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5051                    true
5052                }
5053            };
5054            if failed {
5055                let mut map = artifacts.lock().await;
5056                if let Some(entry) = map.get_mut(&run_id) {
5057                    entry.pdf_path = None;
5058                }
5059            }
5060        });
5061    }
5062}
5063
5064/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
5065/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
5066/// result page can show an error on the next visit instead of spinning indefinitely.
5067fn spawn_native_pdf_background(
5068    json_path: PathBuf,
5069    pdf_dest: PathBuf,
5070    run_id: String,
5071    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5072) {
5073    tokio::spawn(async move {
5074        let result = tokio::task::spawn_blocking(move || {
5075            let run = sloc_core::read_json(&json_path)?;
5076            write_pdf_from_run(&run, &pdf_dest)
5077        })
5078        .await;
5079        let failed = match result {
5080            Ok(Ok(())) => false,
5081            Ok(Err(err)) => {
5082                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5083                true
5084            }
5085            Err(err) => {
5086                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5087                true
5088            }
5089        };
5090        if failed {
5091            let mut map = artifacts.lock().await;
5092            if let Some(entry) = map.get_mut(&run_id) {
5093                entry.pdf_path = None;
5094            }
5095        }
5096    });
5097}
5098
5099/// Sum the code lines added in this comparison (new + grown files).
5100fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5101    cmp.file_deltas
5102        .iter()
5103        .map(|f| match f.status {
5104            FileChangeStatus::Added => f.current_code,
5105            FileChangeStatus::Modified => f.code_delta.max(0),
5106            _ => 0,
5107        })
5108        .sum()
5109}
5110
5111/// Sum the code lines removed in this comparison (deleted + shrunk files).
5112fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5113    cmp.file_deltas
5114        .iter()
5115        .map(|f| match f.status {
5116            FileChangeStatus::Removed => f.baseline_code,
5117            FileChangeStatus::Modified => (-f.code_delta).max(0),
5118            _ => 0,
5119        })
5120        .sum()
5121}
5122
5123/// Sum the code lines present in both scans without any change (Unchanged files).
5124fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5125    cmp.file_deltas
5126        .iter()
5127        .filter(|f| f.status == FileChangeStatus::Unchanged)
5128        .map(|f| f.current_code)
5129        .sum()
5130}
5131
5132/// Sum the code lines residing in files that were modified between the two scans.
5133fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5134    cmp.file_deltas
5135        .iter()
5136        .filter(|f| f.status == FileChangeStatus::Modified)
5137        .map(|f| f.current_code)
5138        .sum()
5139}
5140
5141/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
5142fn build_submodule_row(
5143    s: &sloc_core::SubmoduleSummary,
5144    run: &AnalysisRun,
5145    run_id: &str,
5146    run_dir: &Path,
5147) -> SubmoduleRow {
5148    let safe = sanitize_project_label(&s.name);
5149    let artifact_key = format!("sub_{safe}");
5150    let pdf_artifact_key = format!("sub_{safe}_pdf");
5151    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5152        let parent_path = run
5153            .input_roots
5154            .first()
5155            .map_or("", std::string::String::as_str);
5156        let sub_run = build_sub_run(run, s, parent_path);
5157        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5158        render_sub_report_html(&sub_run, Some(&pdf_server_url))
5159            .ok()
5160            .and_then(|sub_html| {
5161                let sub_dir = run_dir.join("submodules");
5162                let _ = fs::create_dir_all(&sub_dir);
5163                let html_path = sub_dir.join(format!("{artifact_key}.html"));
5164                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5165                    // Pre-generate the sub-report PDF using the programmatic renderer
5166                    // so "View PDF" never needs to spawn Chrome for submodules.
5167                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5168                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
5169                    Some(format!("/runs/{artifact_key}/{run_id}"))
5170                } else {
5171                    None
5172                }
5173            })
5174    } else {
5175        None
5176    };
5177    SubmoduleRow {
5178        name: s.name.clone(),
5179        relative_path: s.relative_path.clone(),
5180        files_analyzed: s.files_analyzed,
5181        code_lines: s.code_lines,
5182        comment_lines: s.comment_lines,
5183        blank_lines: s.blank_lines,
5184        total_physical_lines: s.total_physical_lines,
5185        html_url,
5186    }
5187}
5188
5189// Immediately returns a wait page and runs the analysis in a background tokio task.
5190// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
5191#[allow(clippy::similar_names)]
5192#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
5193#[allow(clippy::too_many_lines)]
5194async fn analyze_handler(
5195    State(state): State<AppState>,
5196    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5197    Form(form): Form<AnalyzeForm>,
5198) -> impl IntoResponse {
5199    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5200        let template = ErrorTemplate {
5201            message: format!(
5202                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5203             Please wait a moment and try again."
5204            ),
5205            last_report_url: None,
5206            last_report_label: None,
5207            run_id: None,
5208            error_code: Some(503),
5209            csp_nonce: csp_nonce.clone(),
5210            version: env!("CARGO_PKG_VERSION"),
5211        };
5212        return (
5213            StatusCode::SERVICE_UNAVAILABLE,
5214            Html(
5215                template
5216                    .render()
5217                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5218            ),
5219        )
5220            .into_response();
5221    };
5222
5223    let mut config = state.base_config.clone();
5224
5225    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5226    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5227    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5228
5229    if !is_git_mode {
5230        let resolved_path = resolve_input_path(&form.path);
5231        if state.server_mode
5232            && !is_upload_tmp_path(&resolved_path)
5233            && !is_sample_path(&resolved_path)
5234        {
5235            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
5236                return resp;
5237            }
5238        }
5239        config.discovery.root_paths = vec![resolved_path];
5240    }
5241
5242    apply_form_to_config(&mut config, &form);
5243    apply_output_dir_exclusions(
5244        &mut config,
5245        &form.path,
5246        form.output_dir.as_deref().unwrap_or(""),
5247    );
5248
5249    // Generate a wait_id now (before spawning) so the client can poll for status.
5250    let wait_id = uuid::Uuid::new_v4().to_string();
5251    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5252
5253    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
5254    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5255    let task_cancel = Arc::clone(&cancel_token);
5256
5257    // Phase tracker: updated by run_analysis_task at key checkpoints.
5258    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5259    let task_phase = Arc::clone(&phase);
5260
5261    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5262    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5263    let task_files_done = Arc::clone(&files_done);
5264    let task_files_total = Arc::clone(&files_total);
5265
5266    // Register Running state before building the task struct so the semaphore permit
5267    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
5268    {
5269        let mut runs = state.async_runs.lock().await;
5270        runs.insert(
5271            wait_id.clone(),
5272            AsyncRunState::Running {
5273                started_at: std::time::Instant::now(),
5274                cancel_token,
5275                phase,
5276                files_done,
5277                files_total,
5278            },
5279        );
5280    }
5281
5282    let task = AnalysisTask {
5283        sem_permit,
5284        state: state.clone(),
5285        wait_id: wait_id.clone(),
5286        config,
5287        cancel: task_cancel,
5288        phase: task_phase,
5289        files_done: task_files_done,
5290        files_total: task_files_total,
5291        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5292        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5293        project_path: form.path.clone(),
5294        // In server mode the client-supplied output_dir is ignored — artifacts are
5295        // always written under the server's configured output root so remote users
5296        // cannot direct writes to arbitrary filesystem paths.
5297        output_dir: if state.server_mode {
5298            None
5299        } else {
5300            form.output_dir.clone()
5301        },
5302        clones_dir: state.git_clones_dir.clone(),
5303        cocomo_mode: form
5304            .cocomo_mode
5305            .clone()
5306            .unwrap_or_else(|| "organic".to_string()),
5307        complexity_alert: form
5308            .complexity_alert
5309            .as_deref()
5310            .and_then(|s| s.parse::<u32>().ok())
5311            .unwrap_or(0),
5312        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5313    };
5314
5315    tokio::spawn(run_analysis_task(task));
5316
5317    let template = ScanWaitTemplate {
5318        version: env!("CARGO_PKG_VERSION"),
5319        wait_id_json,
5320        project_path: form.path.clone(),
5321        csp_nonce,
5322    };
5323    let html = template
5324        .render()
5325        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5326    let mut response = Html(html).into_response();
5327    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
5328        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
5329            response.headers_mut().insert(name, val);
5330        }
5331    }
5332    response
5333}
5334
5335struct AnalysisTask {
5336    sem_permit: tokio::sync::OwnedSemaphorePermit,
5337    state: AppState,
5338    wait_id: String,
5339    config: AppConfig,
5340    cancel: Arc<std::sync::atomic::AtomicBool>,
5341    phase: Arc<std::sync::Mutex<String>>,
5342    files_done: Arc<std::sync::atomic::AtomicUsize>,
5343    files_total: Arc<std::sync::atomic::AtomicUsize>,
5344    git_repo: Option<String>,
5345    git_ref: Option<String>,
5346    project_path: String,
5347    output_dir: Option<String>,
5348    clones_dir: PathBuf,
5349    cocomo_mode: String,
5350    complexity_alert: u32,
5351    exclude_duplicates: bool,
5352}
5353
5354#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
5355async fn run_analysis_task(task: AnalysisTask) {
5356    let _permit = task.sem_permit;
5357
5358    let cancel_sb = Arc::clone(&task.cancel);
5359    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5360    let clones_dir_sb = task.clones_dir;
5361    // Save the upload staging path before config is moved into spawn_blocking.
5362    let upload_staging_root = task
5363        .config
5364        .discovery
5365        .root_paths
5366        .first()
5367        .filter(|p| is_upload_tmp_path(p))
5368        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5369        .map(PathBuf::from);
5370    let config_sb = task.config;
5371    let progress_sb = sloc_core::ProgressCounters {
5372        files_done: Arc::clone(&task.files_done),
5373        files_total: Arc::clone(&task.files_total),
5374    };
5375    if let Ok(mut p) = task.phase.lock() {
5376        *p = "Scanning files".to_string();
5377    }
5378    let analysis_result = tokio::task::spawn_blocking(move || {
5379        run_analysis_blocking(
5380            config_sb,
5381            git_repo_sb,
5382            git_ref_sb,
5383            clones_dir_sb,
5384            cancel_sb,
5385            Some(progress_sb),
5386        )
5387    })
5388    .await
5389    .map_err(|err| anyhow::anyhow!(err.to_string()))
5390    .and_then(|result| result);
5391
5392    if let Ok(mut p) = task.phase.lock() {
5393        *p = "Writing reports".to_string();
5394    }
5395
5396    // If cancelled while running, discard results and mark as cancelled.
5397    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5398        let mut runs = task.state.async_runs.lock().await;
5399        // Only overwrite if still Running (don't clobber a Complete that snuck in).
5400        if matches!(
5401            runs.get(&task.wait_id),
5402            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5403        ) {
5404            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5405        }
5406        drop(runs);
5407        return;
5408    }
5409
5410    let run = match analysis_result {
5411        Ok(v) => v,
5412        Err(err) => {
5413            // Distinguish user-cancelled from real failure.
5414            if err.to_string().contains("analysis cancelled") {
5415                let mut runs = task.state.async_runs.lock().await;
5416                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5417                drop(runs);
5418                return;
5419            }
5420            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5421            let mut runs = task.state.async_runs.lock().await;
5422            runs.insert(
5423                task.wait_id.clone(),
5424                AsyncRunState::Failed {
5425                    message: "Analysis failed. Check that the path exists and is readable."
5426                        .to_string(),
5427                },
5428            );
5429            drop(runs);
5430            return;
5431        }
5432    };
5433
5434    let run_id = run.tool.run_id.clone();
5435    tracing::info!(event = "scan_complete", run_id = %run_id,
5436        path = %task.project_path, files = run.summary_totals.files_analyzed,
5437        "Analysis finished");
5438
5439    let prev_entry: Option<RegistryEntry> = {
5440        let reg = task.state.registry.lock().await;
5441        reg.entries_for_roots(&run.input_roots)
5442            .into_iter()
5443            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5444            .cloned()
5445    };
5446
5447    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5448        prev.json_path
5449            .as_ref()
5450            .and_then(|p| read_json(p).ok())
5451            .map(|prev_run| compute_delta(&prev_run, &run))
5452    });
5453    let prev_scan_count: usize = {
5454        let reg = task.state.registry.lock().await;
5455        reg.entries_for_roots(&run.input_roots)
5456            .iter()
5457            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5458            .count()
5459    };
5460
5461    // Build the HTML report now that delta is available, so the artifact
5462    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5463    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5464        .as_ref()
5465        .zip(prev_entry.as_ref())
5466        .map(|(cmp, prev)| ReportDeltaContext {
5467            delta_code_added: sum_added_code_lines(cmp),
5468            delta_code_removed: sum_removed_code_lines(cmp),
5469            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5470            delta_files_added: cmp.files_added,
5471            delta_files_removed: cmp.files_removed,
5472            delta_files_modified: cmp.files_modified,
5473            delta_files_unchanged: cmp.files_unchanged,
5474            prev_code_lines: prev.summary.code_lines,
5475            prev_scan_count: prev_scan_count + 1,
5476            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5477            prev_run_id: Some(prev.run_id.clone()),
5478            current_run_id: Some(run_id.clone()),
5479        });
5480    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5481        Ok(h) => h,
5482        Err(err) => {
5483            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5484            let mut runs = task.state.async_runs.lock().await;
5485            runs.insert(
5486                task.wait_id.clone(),
5487                AsyncRunState::Failed {
5488                    message: "Failed to render HTML report.".to_string(),
5489                },
5490            );
5491            drop(runs);
5492            return;
5493        }
5494    };
5495
5496    let output_root = resolve_output_root(task.output_dir.as_deref());
5497    let project_label = derive_project_label(
5498        task.git_repo.as_deref(),
5499        task.git_ref.as_deref(),
5500        &task.project_path,
5501    );
5502    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5503    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5504
5505    let result_context = RunResultContext {
5506        prev_entry: prev_entry.clone(),
5507        prev_scan_count,
5508        project_path: task.project_path.clone(),
5509        cocomo_mode: task.cocomo_mode.clone(),
5510        complexity_alert: task.complexity_alert,
5511        exclude_duplicates: task.exclude_duplicates,
5512    };
5513
5514    let artifact_result = persist_run_artifacts(
5515        &run,
5516        &report_html,
5517        &run_dir,
5518        &run.effective_configuration.reporting.report_title,
5519        &file_stem,
5520        result_context,
5521    );
5522
5523    let (artifacts, pending_pdf) = match artifact_result {
5524        Ok(v) => v,
5525        Err(err) => {
5526            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5527            let mut runs = task.state.async_runs.lock().await;
5528            runs.insert(
5529                task.wait_id.clone(),
5530                AsyncRunState::Failed {
5531                    message: "Failed to save report artifacts. Check available disk space."
5532                        .to_string(),
5533                },
5534            );
5535            drop(runs);
5536            return;
5537        }
5538    };
5539
5540    {
5541        let mut map = task.state.artifacts.lock().await;
5542        map.insert(run_id.clone(), artifacts.clone());
5543    }
5544
5545    {
5546        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5547        let mut reg = task.state.registry.lock().await;
5548        reg.add_entry(entry);
5549        let _ = reg.save(&task.state.registry_path);
5550    }
5551
5552    if let Some(ref cfg_path) = artifacts.scan_config_path {
5553        save_scan_config_json(
5554            cfg_path,
5555            &run,
5556            &task.project_path,
5557            task.output_dir.as_deref(),
5558            &task.cocomo_mode,
5559            task.complexity_alert,
5560            task.exclude_duplicates,
5561        );
5562    }
5563
5564    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5565
5566    prom_runs_total().inc();
5567
5568    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5569    let mut runs = task.state.async_runs.lock().await;
5570    runs.insert(
5571        task.wait_id.clone(),
5572        AsyncRunState::Complete {
5573            run_id: run_id.clone(),
5574        },
5575    );
5576    drop(runs);
5577
5578    // Remove the client-upload staging directory after a successful scan so
5579    // that uploaded project files don't accumulate in the OS temp directory.
5580    if let Some(staging) = upload_staging_root {
5581        let _ = tokio::fs::remove_dir_all(staging).await;
5582    }
5583
5584    let _ = scan_delta;
5585}
5586
5587fn save_scan_config_json(
5588    cfg_path: &std::path::Path,
5589    run: &sloc_core::AnalysisRun,
5590    project_path: &str,
5591    output_dir: Option<&str>,
5592    cocomo_mode: &str,
5593    complexity_alert: u32,
5594    exclude_duplicates: bool,
5595) {
5596    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5597        .ok()
5598        .and_then(|v| v.as_str().map(String::from))
5599        .unwrap_or_else(|| "code_only".to_string());
5600    let behavior_str =
5601        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5602            .ok()
5603            .and_then(|v| v.as_str().map(String::from))
5604            .unwrap_or_else(|| "skip".to_string());
5605    let continuation_policy_str = serde_json::to_value(
5606        run.effective_configuration
5607            .analysis
5608            .continuation_line_policy,
5609    )
5610    .ok()
5611    .and_then(|v| v.as_str().map(String::from))
5612    .unwrap_or_else(default_each_physical_line);
5613    let blank_policy_str = serde_json::to_value(
5614        run.effective_configuration
5615            .analysis
5616            .blank_in_block_comment_policy,
5617    )
5618    .ok()
5619    .and_then(|v| v.as_str().map(String::from))
5620    .unwrap_or_else(default_count_as_comment);
5621    let scan_cfg = ScanConfig {
5622        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5623        path: project_path.to_string(),
5624        include_globs: run
5625            .effective_configuration
5626            .discovery
5627            .include_globs
5628            .join("\n"),
5629        exclude_globs: run
5630            .effective_configuration
5631            .discovery
5632            .exclude_globs
5633            .join("\n"),
5634        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5635        mixed_line_policy: policy_str,
5636        python_docstrings_as_comments: run
5637            .effective_configuration
5638            .analysis
5639            .python_docstrings_as_comments,
5640        generated_file_detection: run
5641            .effective_configuration
5642            .analysis
5643            .generated_file_detection,
5644        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5645        vendor_directory_detection: run
5646            .effective_configuration
5647            .analysis
5648            .vendor_directory_detection,
5649        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5650        binary_file_behavior: behavior_str,
5651        output_dir: output_dir.unwrap_or("").to_string(),
5652        report_title: run.effective_configuration.reporting.report_title.clone(),
5653        continuation_line_policy: continuation_policy_str,
5654        blank_in_block_comment_policy: blank_policy_str,
5655        count_compiler_directives: run
5656            .effective_configuration
5657            .analysis
5658            .count_compiler_directives,
5659        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5660        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5661        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5662        style_lang_scope: run
5663            .effective_configuration
5664            .analysis
5665            .style_lang_scope
5666            .clone(),
5667        coverage_file: run
5668            .effective_configuration
5669            .analysis
5670            .coverage_file
5671            .as_ref()
5672            .map(|p| p.display().to_string())
5673            .unwrap_or_default(),
5674        cocomo_mode: cocomo_mode.to_string(),
5675        complexity_alert,
5676        exclude_duplicates,
5677        activity_window: run
5678            .effective_configuration
5679            .analysis
5680            .activity_window_days
5681            .unwrap_or(0),
5682    };
5683    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5684        let _ = std::fs::write(cfg_path, json);
5685    }
5686}
5687
5688#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5689fn run_analysis_blocking(
5690    mut config: AppConfig,
5691    git_repo: Option<String>,
5692    git_ref: Option<String>,
5693    clones_dir: PathBuf,
5694    cancel: Arc<std::sync::atomic::AtomicBool>,
5695    progress: Option<sloc_core::ProgressCounters>,
5696) -> Result<sloc_core::AnalysisRun> {
5697    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5698        let dest = git_clone_dest(&repo, &clones_dir);
5699        sloc_git::clone_or_fetch(&repo, &dest)?;
5700        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5701        sloc_git::create_worktree(&dest, &refname, &wt)?;
5702        config.discovery.root_paths = vec![wt.clone()];
5703        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5704        let _ = sloc_git::destroy_worktree(&dest, &wt);
5705        let mut run = run?;
5706        if run.git_branch.is_none() {
5707            run.git_branch = Some(refname);
5708        }
5709        return Ok(run);
5710    }
5711    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5712}
5713
5714fn derive_project_label(
5715    git_repo: Option<&str>,
5716    git_ref: Option<&str>,
5717    fallback_path: &str,
5718) -> String {
5719    match (
5720        git_repo.filter(|s| !s.is_empty()),
5721        git_ref.filter(|s| !s.is_empty()),
5722    ) {
5723        (Some(repo), Some(refname)) => {
5724            let repo_name = repo
5725                .trim_end_matches('/')
5726                .trim_end_matches(".git")
5727                .rsplit('/')
5728                .next()
5729                .unwrap_or("repo");
5730            sanitize_project_label(&format!("{repo_name}_{refname}"))
5731        }
5732        _ => sanitize_project_label(fallback_path),
5733    }
5734}
5735
5736fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5737    let commit = commit_short.unwrap_or("").trim();
5738    if commit.is_empty() {
5739        project_label.to_string()
5740    } else {
5741        format!("{project_label}_{commit}")
5742    }
5743}
5744
5745// ── Async scan status + result handlers ──────────────────────────────────────
5746
5747#[derive(Serialize)]
5748#[serde(tag = "state", rename_all = "snake_case")]
5749enum AsyncRunStatusResponse {
5750    Running {
5751        elapsed_secs: u64,
5752        phase: String,
5753        files_done: u64,
5754        files_total: u64,
5755    },
5756    Complete {
5757        run_id: String,
5758    },
5759    Failed {
5760        message: String,
5761    },
5762    Cancelled,
5763}
5764
5765async fn async_run_status_handler(
5766    State(state): State<AppState>,
5767    AxumPath(wait_id): AxumPath<String>,
5768) -> Response {
5769    // wait_id comes from our own UUID generator; reject any structurally malformed value.
5770    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5771        return error::bad_request("invalid wait_id");
5772    }
5773    let run_state = {
5774        let runs = state.async_runs.lock().await;
5775        runs.get(&wait_id).cloned()
5776    };
5777    match run_state {
5778        None => error::not_found("run not found"),
5779        Some(AsyncRunState::Running {
5780            started_at,
5781            phase,
5782            files_done,
5783            files_total,
5784            ..
5785        }) => {
5786            // Treat runs older than 2 h as timed out (analysis should finish well under that).
5787            if started_at.elapsed() > std::time::Duration::from_hours(2) {
5788                let mut runs = state.async_runs.lock().await;
5789                runs.insert(
5790                    wait_id,
5791                    AsyncRunState::Failed {
5792                        message: "Analysis timed out after 2 hours.".to_string(),
5793                    },
5794                );
5795                drop(runs);
5796                return Json(AsyncRunStatusResponse::Failed {
5797                    message: "Analysis timed out after 2 hours.".to_string(),
5798                })
5799                .into_response();
5800            }
5801            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5802            Json(AsyncRunStatusResponse::Running {
5803                elapsed_secs: started_at.elapsed().as_secs(),
5804                phase: phase_str,
5805                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5806                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5807            })
5808            .into_response()
5809        }
5810        Some(AsyncRunState::Complete { run_id }) => {
5811            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5812        }
5813        Some(AsyncRunState::Failed { message }) => {
5814            Json(AsyncRunStatusResponse::Failed { message }).into_response()
5815        }
5816        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5817    }
5818}
5819
5820async fn cancel_run_handler(
5821    State(state): State<AppState>,
5822    AxumPath(wait_id): AxumPath<String>,
5823) -> Response {
5824    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5825        return error::bad_request("invalid wait_id");
5826    }
5827    let mut runs = state.async_runs.lock().await;
5828    let resp = match runs.get(&wait_id) {
5829        Some(AsyncRunState::Running { cancel_token, .. }) => {
5830            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5831            runs.insert(wait_id, AsyncRunState::Cancelled);
5832            StatusCode::OK.into_response()
5833        }
5834        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5835        _ => error::not_found("run not found"),
5836    };
5837    drop(runs);
5838    resp
5839}
5840
5841async fn async_run_result_handler(
5842    State(state): State<AppState>,
5843    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5844    AxumPath(run_id): AxumPath<String>,
5845) -> Response {
5846    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5847        return StatusCode::BAD_REQUEST.into_response();
5848    }
5849
5850    let artifacts = {
5851        let map = state.artifacts.lock().await;
5852        map.get(&run_id).cloned()
5853    };
5854    let artifacts = if let Some(a) = artifacts {
5855        a
5856    } else {
5857        let reg = state.registry.lock().await;
5858        if let Some(entry) = reg.find_by_run_id(&run_id) {
5859            recover_artifacts_from_registry(entry)
5860        } else {
5861            let html = ErrorTemplate {
5862                message: format!(
5863                    "Report not found. Run ID {} is not in the scan history.",
5864                    &run_id[..run_id.len().min(8)]
5865                ),
5866                last_report_url: Some("/view-reports".to_string()),
5867                last_report_label: Some("View Reports".to_string()),
5868                run_id: Some(run_id.clone()),
5869                error_code: Some(404),
5870                csp_nonce: csp_nonce.clone(),
5871                version: env!("CARGO_PKG_VERSION"),
5872            }
5873            .render()
5874            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5875            return (StatusCode::NOT_FOUND, Html(html)).into_response();
5876        }
5877    };
5878
5879    let json_path = if let Some(p) = &artifacts.json_path {
5880        p.clone()
5881    } else {
5882        let html = ErrorTemplate {
5883            message: "JSON result was not saved for this run.".to_string(),
5884            last_report_url: Some("/view-reports".to_string()),
5885            last_report_label: Some("View Reports".to_string()),
5886            run_id: Some(run_id.clone()),
5887            error_code: Some(404),
5888            csp_nonce: csp_nonce.clone(),
5889            version: env!("CARGO_PKG_VERSION"),
5890        }
5891        .render()
5892        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5893        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5894    };
5895
5896    let Ok(run) = read_json(&json_path) else {
5897        let folder_hint = output_folder_hint(&json_path);
5898        let redirect_url = format!("/runs/result/{run_id}");
5899        return missing_scan_relocate_response(
5900            &format!(
5901                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
5902                 deleted. Browse to the folder containing your scan output to reconnect it.",
5903                json_path.display()
5904            ),
5905            &run_id,
5906            &folder_hint,
5907            &redirect_url,
5908            state.server_mode,
5909            &csp_nonce,
5910        );
5911    };
5912
5913    let confluence_configured = {
5914        let store = state.confluence.lock().await;
5915        store.is_configured()
5916    };
5917
5918    render_result_page(
5919        &run,
5920        &artifacts,
5921        &run_id,
5922        &csp_nonce,
5923        confluence_configured,
5924        state.server_mode,
5925    )
5926}
5927
5928/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
5929fn json_escape(s: &str) -> String {
5930    s.replace('\\', "\\\\").replace('"', "\\\"")
5931}
5932
5933/// Per-language line/symbol totals summed across every language in a run.
5934struct LangTotals {
5935    physical_lines: u64,
5936    code_lines: u64,
5937    comment_lines: u64,
5938    blank_lines: u64,
5939    mixed_lines: u64,
5940    functions: u64,
5941    classes: u64,
5942    variables: u64,
5943    imports: u64,
5944}
5945
5946fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5947    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5948        run.totals_by_language.iter().map(f).sum()
5949    };
5950    LangTotals {
5951        physical_lines: s(|r| r.total_physical_lines),
5952        code_lines: s(|r| r.code_lines),
5953        comment_lines: s(|r| r.comment_lines),
5954        blank_lines: s(|r| r.blank_lines),
5955        mixed_lines: s(|r| r.mixed_lines_separate),
5956        functions: s(|r| r.functions),
5957        classes: s(|r| r.classes),
5958        variables: s(|r| r.variables),
5959        imports: s(|r| r.imports),
5960    }
5961}
5962
5963/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
5964struct DeltaFields {
5965    prev_fa_str: String,
5966    prev_fs_str: String,
5967    prev_pl_str: String,
5968    prev_cl_str: String,
5969    prev_cml_str: String,
5970    prev_bl_str: String,
5971    delta_fa_str: String,
5972    delta_fa_class: String,
5973    delta_fs_str: String,
5974    delta_fs_class: String,
5975    delta_pl_str: String,
5976    delta_pl_class: String,
5977    delta_cl_str: String,
5978    delta_cl_class: String,
5979    delta_cml_str: String,
5980    delta_cml_class: String,
5981    delta_bl_str: String,
5982    delta_bl_class: String,
5983    delta_lines_added: Option<i64>,
5984    delta_lines_removed: Option<i64>,
5985    delta_lines_net_str: String,
5986    delta_lines_net_class: String,
5987}
5988
5989// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
5990// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
5991// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
5992// from those field names and obscure the 1:1 mapping.
5993#[allow(
5994    clippy::similar_names,
5995    reason = "locals mirror template-bound struct fields"
5996)]
5997fn compute_delta_fields(
5998    prev_entry: Option<&RegistryEntry>,
5999    totals: &LangTotals,
6000    files_analyzed: u64,
6001    files_skipped: u64,
6002    scan_delta: Option<&sloc_core::ScanComparison>,
6003) -> DeltaFields {
6004    let prev_sum = prev_entry.map(|e| &e.summary);
6005    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6006
6007    let (delta_fa_str, delta_fa_class) =
6008        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6009    let (delta_fs_str, delta_fs_class) =
6010        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6011    let (delta_pl_str, delta_pl_class) = summary_delta(
6012        totals.physical_lines,
6013        prev_sum.map(|s| s.total_physical_lines),
6014    );
6015    let (delta_cl_str, delta_cl_class) =
6016        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6017    let (delta_cml_str, delta_cml_class) =
6018        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6019    let (delta_bl_str, delta_bl_class) =
6020        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6021
6022    let delta_lines_added = scan_delta.map(sum_added_code_lines);
6023    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6024    let (delta_lines_net_str, delta_lines_net_class) =
6025        match (delta_lines_added, delta_lines_removed) {
6026            (Some(a), Some(r)) => {
6027                let net = a - r;
6028                (fmt_delta(net), delta_class(net).to_string())
6029            }
6030            _ => ("\u{2014}".to_string(), "na".to_string()),
6031        };
6032
6033    DeltaFields {
6034        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6035        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6036        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6037        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6038        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6039        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6040        delta_fa_str,
6041        delta_fa_class: delta_fa_class.to_string(),
6042        delta_fs_str,
6043        delta_fs_class: delta_fs_class.to_string(),
6044        delta_pl_str,
6045        delta_pl_class: delta_pl_class.to_string(),
6046        delta_cl_str,
6047        delta_cl_class: delta_cl_class.to_string(),
6048        delta_cml_str,
6049        delta_cml_class: delta_cml_class.to_string(),
6050        delta_bl_str,
6051        delta_bl_class: delta_bl_class.to_string(),
6052        delta_lines_added,
6053        delta_lines_removed,
6054        delta_lines_net_str,
6055        delta_lines_net_class,
6056    }
6057}
6058
6059/// Count of unchanged code lines in a scan comparison.
6060fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6061    scan_delta
6062        .file_deltas
6063        .iter()
6064        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6065        .map(|f| {
6066            #[allow(clippy::cast_sign_loss)]
6067            let n = f.current_code as u64;
6068            n
6069        })
6070        .sum()
6071}
6072
6073fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6074    run.git_remote_url
6075        .as_deref()
6076        .zip(run.git_commit_long.as_deref())
6077        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6078}
6079
6080fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6081    run.git_remote_url
6082        .as_deref()
6083        .zip(run.git_branch.as_deref())
6084        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6085}
6086
6087fn scan_performed_by(run: &AnalysisRun) -> String {
6088    run.environment.ci_name.clone().unwrap_or_else(|| {
6089        format!(
6090            "{} / {}",
6091            run.environment.initiator_username, run.environment.initiator_hostname
6092        )
6093    })
6094}
6095
6096/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
6097fn build_lang_chart_json(run: &AnalysisRun) -> String {
6098    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6099    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6100    let entries: Vec<String> = langs
6101        .into_iter()
6102        .take(12)
6103        .map(|l| {
6104            let name = json_escape(l.language.display_name());
6105            format!(
6106                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6107                name,
6108                l.code_lines,
6109                l.comment_lines,
6110                l.blank_lines,
6111                l.total_physical_lines,
6112                l.functions,
6113                l.classes,
6114                l.variables,
6115                l.imports,
6116                l.files,
6117            )
6118        })
6119        .collect();
6120    format!("[{}]", entries.join(","))
6121}
6122
6123/// Per-language files-vs-lines points as a JSON array for the scatter chart.
6124fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6125    let entries: Vec<String> = run
6126        .totals_by_language
6127        .iter()
6128        .map(|l| {
6129            let name = json_escape(l.language.display_name());
6130            format!(
6131                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6132                name, l.files, l.code_lines, l.total_physical_lines,
6133            )
6134        })
6135        .collect();
6136    format!("[{}]", entries.join(","))
6137}
6138
6139/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
6140fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6141    let entries: Vec<String> = run
6142        .totals_by_language
6143        .iter()
6144        .filter(|l| {
6145            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6146        })
6147        .map(|l| {
6148            let name = json_escape(l.language.display_name());
6149            format!(
6150                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6151                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6152            )
6153        })
6154        .collect();
6155    format!("[{}]", entries.join(","))
6156}
6157
6158/// Per-submodule line counts as a JSON array for the submodule chart.
6159fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6160    let entries: Vec<String> = run
6161        .submodule_summaries
6162        .iter()
6163        .map(|s| {
6164            let name = json_escape(&s.name);
6165            format!(
6166                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6167                name,
6168                s.code_lines,
6169                s.comment_lines,
6170                s.blank_lines,
6171                s.total_physical_lines,
6172                s.files_analyzed,
6173            )
6174        })
6175        .collect();
6176    format!("[{}]", entries.join(","))
6177}
6178
6179/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
6180#[allow(clippy::cast_precision_loss)]
6181fn cov_pct_str(hit: u64, found: u64) -> String {
6182    if found > 0 {
6183        format!("{:.1}", hit as f64 / found as f64 * 100.0)
6184    } else {
6185        String::new()
6186    }
6187}
6188
6189/// `hit / found` summary string, or empty when nothing was found.
6190fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6191    if found > 0 {
6192        format!("{hit} / {found}")
6193    } else {
6194        String::new()
6195    }
6196}
6197
6198const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6199    use sloc_core::CocomoMode;
6200    match mode {
6201        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6202        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6203        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6204    }
6205}
6206
6207const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6208    use sloc_core::CocomoMode;
6209    match mode {
6210        CocomoMode::Organic => "Organic",
6211        CocomoMode::SemiDetached => "Semi-detached",
6212        CocomoMode::Embedded => "Embedded",
6213    }
6214}
6215
6216const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6217    use sloc_core::CocomoMode;
6218    match mode {
6219        CocomoMode::Organic => {
6220            "Organic: A small team working on a well-understood project in a familiar \
6221             environment with minimal external constraints. Suited for internal tools, \
6222             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6223        }
6224        CocomoMode::SemiDetached => {
6225            "Semi-detached: A mixed team with varying experience tackling a project with \
6226             moderate novelty and some rigid constraints. Typical for compilers, transaction \
6227             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6228        }
6229        CocomoMode::Embedded => {
6230            "Embedded: Tight hardware, software, or operational constraints requiring \
6231             significant innovation and deep integration work. Typical for real-time control \
6232             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6233        }
6234    }
6235}
6236
6237/// COCOMO display strings recomputed for the scan-wizard-selected mode.
6238struct CocomoFields {
6239    has_cocomo: bool,
6240    effort_str: String,
6241    duration_str: String,
6242    staff_str: String,
6243    ksloc_str: String,
6244    mode_label: String,
6245    mode_tooltip: String,
6246}
6247
6248#[allow(clippy::cast_precision_loss)]
6249fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6250    use sloc_core::CocomoMode;
6251    let mode = match mode_str {
6252        "semi_detached" => CocomoMode::SemiDetached,
6253        "embedded" => CocomoMode::Embedded,
6254        _ => CocomoMode::Organic,
6255    };
6256    let (a, b, c, d) = cocomo_coefficients(mode);
6257    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6258    let effort = a * ksloc.powf(b);
6259    let duration = c * effort.powf(d);
6260    let staff = if duration > 0.0 {
6261        effort / duration
6262    } else {
6263        0.0
6264    };
6265    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6266    let mode_label = cocomo_mode_label(mode).to_string();
6267    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6268    if run.summary_totals.code_lines > 0 {
6269        CocomoFields {
6270            has_cocomo: true,
6271            effort_str: round2(effort),
6272            duration_str: round2(duration),
6273            staff_str: round2(staff),
6274            ksloc_str: round2(ksloc),
6275            mode_label,
6276            mode_tooltip,
6277        }
6278    } else {
6279        CocomoFields {
6280            has_cocomo: false,
6281            effort_str: String::new(),
6282            duration_str: String::new(),
6283            staff_str: String::new(),
6284            ksloc_str: String::new(),
6285            mode_label,
6286            mode_tooltip,
6287        }
6288    }
6289}
6290
6291#[allow(clippy::too_many_lines)]
6292#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
6293#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
6294fn render_result_page(
6295    run: &AnalysisRun,
6296    artifacts: &RunArtifacts,
6297    run_id: &str,
6298    csp_nonce: &str,
6299    confluence_configured: bool,
6300    server_mode: bool,
6301) -> Response {
6302    let ctx = &artifacts.result_context;
6303    let prev_entry = &ctx.prev_entry;
6304    let prev_scan_count = ctx.prev_scan_count;
6305    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
6306    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
6307    // field is never blank.
6308    let project_path_owned = if ctx.project_path.is_empty() {
6309        run.input_roots.join(", ")
6310    } else {
6311        ctx.project_path.clone()
6312    };
6313    let project_path = &project_path_owned;
6314
6315    let scan_delta = prev_entry.as_ref().and_then(|prev| {
6316        prev.json_path
6317            .as_ref()
6318            .and_then(|p| read_json(p).ok())
6319            .map(|prev_run| compute_delta(&prev_run, run))
6320    });
6321
6322    let files_analyzed = run.per_file_records.len() as u64;
6323    let files_skipped = run.skipped_file_records.len() as u64;
6324    let totals = sum_lang_totals(run);
6325
6326    let DeltaFields {
6327        prev_fa_str,
6328        prev_fs_str,
6329        prev_pl_str,
6330        prev_cl_str,
6331        prev_cml_str,
6332        prev_bl_str,
6333        delta_fa_str,
6334        delta_fa_class,
6335        delta_fs_str,
6336        delta_fs_class,
6337        delta_pl_str,
6338        delta_pl_class,
6339        delta_cl_str,
6340        delta_cl_class,
6341        delta_cml_str,
6342        delta_cml_class,
6343        delta_bl_str,
6344        delta_bl_class,
6345        delta_lines_added,
6346        delta_lines_removed,
6347        delta_lines_net_str,
6348        delta_lines_net_class,
6349    } = compute_delta_fields(
6350        prev_entry.as_ref(),
6351        &totals,
6352        files_analyzed,
6353        files_skipped,
6354        scan_delta.as_ref(),
6355    );
6356
6357    let run_dir = artifacts.output_dir.clone();
6358    let git_branch = run.git_branch.clone();
6359    let git_commit = run.git_commit_short.clone();
6360    let git_commit_long = run.git_commit_long.clone();
6361    let git_author = run.git_commit_author.clone();
6362    let git_commit_url = git_commit_url_for(run);
6363    let git_branch_url = git_branch_url_for(run);
6364    let scan_performed_by = scan_performed_by(run);
6365    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6366    let os_display = format!(
6367        "{} / {}",
6368        run.environment.operating_system, run.environment.architecture
6369    );
6370    let test_count = run.summary_totals.test_count;
6371
6372    // ── New metrics ──────────────────────────────────────────────────────────
6373    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6374    let lsloc = run.summary_totals.lsloc;
6375    let uloc = run.uloc;
6376    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6377    let duplicate_group_count = run.duplicate_groups.len();
6378
6379    // Re-compute COCOMO with the mode selected in the scan wizard.
6380    let ctx = &artifacts.result_context;
6381    let CocomoFields {
6382        has_cocomo,
6383        effort_str: cocomo_effort_str,
6384        duration_str: cocomo_duration_str,
6385        staff_str: cocomo_staff_str,
6386        ksloc_str: cocomo_ksloc_str,
6387        mode_label: cocomo_mode_label,
6388        mode_tooltip: cocomo_mode_tooltip,
6389    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6390    let complexity_alert = ctx.complexity_alert;
6391
6392    let template = ResultTemplate {
6393        version: env!("CARGO_PKG_VERSION"),
6394        report_title: run.effective_configuration.reporting.report_title.clone(),
6395        project_path: project_path.clone(),
6396        output_dir: display_path(&artifacts.output_dir),
6397        run_id: run_id.to_owned(),
6398        run_id_short: run_id
6399            .split('-')
6400            .next_back()
6401            .unwrap_or(run_id)
6402            .chars()
6403            .take(7)
6404            .collect(),
6405        files_analyzed,
6406        files_skipped,
6407        physical_lines: totals.physical_lines,
6408        code_lines: totals.code_lines,
6409        comment_lines: totals.comment_lines,
6410        blank_lines: totals.blank_lines,
6411        mixed_lines: totals.mixed_lines,
6412        functions: totals.functions,
6413        classes: totals.classes,
6414        variables: totals.variables,
6415        imports: totals.imports,
6416        html_url: artifacts
6417            .html_path
6418            .as_ref()
6419            .map(|_| format!("/runs/html/{run_id}")),
6420        pdf_url: artifacts
6421            .pdf_path
6422            .as_ref()
6423            .map(|_| format!("/runs/pdf/{run_id}")),
6424        json_url: artifacts
6425            .json_path
6426            .as_ref()
6427            .map(|_| format!("/runs/json/{run_id}")),
6428        html_download_url: artifacts
6429            .html_path
6430            .as_ref()
6431            .map(|_| format!("/runs/html/{run_id}?download=1")),
6432        pdf_download_url: artifacts
6433            .pdf_path
6434            .as_ref()
6435            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6436        json_download_url: artifacts
6437            .json_path
6438            .as_ref()
6439            .map(|_| format!("/runs/json/{run_id}?download=1")),
6440        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6441        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6442        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6443        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6444        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6445        prev_fa_str,
6446        prev_fs_str,
6447        prev_pl_str,
6448        prev_cl_str,
6449        prev_cml_str,
6450        prev_bl_str,
6451        delta_fa_str,
6452        delta_fa_class,
6453        delta_fs_str,
6454        delta_fs_class,
6455        delta_pl_str,
6456        delta_pl_class,
6457        delta_cl_str,
6458        delta_cl_class,
6459        delta_cml_str,
6460        delta_cml_class,
6461        delta_bl_str,
6462        delta_bl_class,
6463        delta_lines_added,
6464        delta_lines_removed,
6465        delta_lines_net_str,
6466        delta_lines_net_class,
6467        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6468        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6469        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6470        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6471        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6472        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6473        git_branch,
6474        git_branch_url,
6475        git_commit,
6476        git_commit_long,
6477        git_author,
6478        git_commit_url,
6479        scan_performed_by,
6480        scan_time_display,
6481        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6482        os_display,
6483        test_count,
6484        test_assertion_count: run.summary_totals.test_assertion_count,
6485        current_scan_number: prev_scan_count + 1,
6486        prev_scan_count,
6487        submodule_rows: run
6488            .submodule_summaries
6489            .iter()
6490            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6491            .collect(),
6492        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6493        scan_config_url: format!("/runs/scan-config/{run_id}"),
6494        lang_chart_json: build_lang_chart_json(run),
6495        scatter_chart_json: build_scatter_chart_json(run),
6496        semantic_chart_json: build_semantic_chart_json(run),
6497        submodule_chart_json: build_submodule_chart_json(run),
6498        has_submodule_data: !run.submodule_summaries.is_empty(),
6499        has_semantic_data: run
6500            .totals_by_language
6501            .iter()
6502            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6503        csp_nonce: csp_nonce.to_owned(),
6504        confluence_configured,
6505        server_mode,
6506        report_header_footer: run
6507            .effective_configuration
6508            .reporting
6509            .report_header_footer
6510            .clone(),
6511        is_offline: false,
6512        cyclomatic_complexity,
6513        lsloc,
6514        uloc,
6515        dryness_pct_str,
6516        duplicate_group_count,
6517        has_cocomo,
6518        cocomo_effort_str,
6519        cocomo_duration_str,
6520        cocomo_staff_str,
6521        cocomo_ksloc_str,
6522        cocomo_mode_label,
6523        cocomo_mode_tooltip,
6524        complexity_alert,
6525        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6526        cov_line_pct: cov_pct_str(
6527            run.summary_totals.coverage_lines_hit,
6528            run.summary_totals.coverage_lines_found,
6529        ),
6530        cov_fn_pct: cov_pct_str(
6531            run.summary_totals.coverage_functions_hit,
6532            run.summary_totals.coverage_functions_found,
6533        ),
6534        cov_branch_pct: cov_pct_str(
6535            run.summary_totals.coverage_branches_hit,
6536            run.summary_totals.coverage_branches_found,
6537        ),
6538        cov_lines_summary: cov_lines_summary_str(
6539            run.summary_totals.coverage_lines_hit,
6540            run.summary_totals.coverage_lines_found,
6541        ),
6542    };
6543
6544    Html(
6545        template
6546            .render()
6547            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6548    )
6549    .into_response()
6550}
6551
6552fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6553    let slug: String = report_title
6554        .chars()
6555        .map(|c| {
6556            if c.is_alphanumeric() || c == '-' {
6557                c.to_ascii_lowercase()
6558            } else {
6559                '_'
6560            }
6561        })
6562        .collect::<String>()
6563        .split('_')
6564        .filter(|s| !s.is_empty())
6565        .collect::<Vec<_>>()
6566        .join("_");
6567
6568    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6569
6570    if slug.is_empty() {
6571        format!("report_{short_id}.pdf")
6572    } else {
6573        format!("{slug}_{short_id}.pdf")
6574    }
6575}
6576
6577#[derive(Serialize)]
6578struct PdfStatusResponse {
6579    ready: bool,
6580}
6581
6582/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6583/// Clients poll this to update the button state without page reloads.
6584async fn pdf_status_handler(
6585    State(state): State<AppState>,
6586    AxumPath(run_id): AxumPath<String>,
6587) -> Response {
6588    let pdf_path = {
6589        let registry = state.artifacts.lock().await;
6590        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6591    };
6592    let pdf_path = if pdf_path.is_some() {
6593        pdf_path
6594    } else {
6595        let reg = state.registry.lock().await;
6596        reg.find_by_run_id(&run_id)
6597            .map(recover_artifacts_from_registry)
6598            .and_then(|a| a.pdf_path)
6599    };
6600    let ready = pdf_path.is_some_and(|p| p.exists());
6601    Json(PdfStatusResponse { ready }).into_response()
6602}
6603
6604/// GET /`api/runs/:run_id/bundle`
6605///
6606/// Streams a gzip-compressed tar archive containing every artifact in the run's
6607/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6608/// is built in memory so it never touches a temp file.
6609async fn download_bundle_handler(
6610    State(state): State<AppState>,
6611    AxumPath(run_id): AxumPath<String>,
6612) -> Response {
6613    // Resolve output directory from in-memory cache or persisted registry.
6614    let output_dir = {
6615        let cache = state.artifacts.lock().await;
6616        cache.get(&run_id).map(|a| a.output_dir.clone())
6617    };
6618    let output_dir = if let Some(d) = output_dir {
6619        d
6620    } else {
6621        let reg = state.registry.lock().await;
6622        match reg.find_by_run_id(&run_id) {
6623            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6624            None => {
6625                return (
6626                    StatusCode::NOT_FOUND,
6627                    Json(serde_json::json!({"error": "Run not found"})),
6628                )
6629                    .into_response();
6630            }
6631        }
6632    };
6633
6634    if !output_dir.exists() {
6635        return (
6636            StatusCode::NOT_FOUND,
6637            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6638        )
6639            .into_response();
6640    }
6641
6642    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6643    let run_id_clone = run_id.clone();
6644    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6645        use flate2::{write::GzEncoder, Compression};
6646        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6647        {
6648            let mut tar = tar::Builder::new(&mut enc);
6649            tar.follow_symlinks(false);
6650            // Append every regular file in the output directory, skipping
6651            // sub-directories (the output dir is always flat).
6652            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6653                for entry in entries.filter_map(Result::ok) {
6654                    let p = entry.path();
6655                    if p.is_file() {
6656                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6657                        let archive_path = format!("{run_id_clone}/{name}");
6658                        tar.append_path_with_name(&p, &archive_path)?;
6659                    }
6660                }
6661            }
6662            tar.finish()?;
6663        }
6664        Ok(enc.finish()?)
6665    })
6666    .await;
6667
6668    match archive_result {
6669        Ok(Ok(bytes)) => {
6670            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6671            axum::response::Response::builder()
6672                .status(StatusCode::OK)
6673                .header("Content-Type", "application/gzip")
6674                .header(
6675                    "Content-Disposition",
6676                    format!("attachment; filename=\"{filename}\""),
6677                )
6678                .header("Content-Length", bytes.len().to_string())
6679                .body(axum::body::Body::from(bytes))
6680                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6681        }
6682        Ok(Err(e)) => (
6683            StatusCode::INTERNAL_SERVER_ERROR,
6684            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6685        )
6686            .into_response(),
6687        Err(e) => (
6688            StatusCode::INTERNAL_SERVER_ERROR,
6689            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6690        )
6691            .into_response(),
6692    }
6693}
6694
6695/// DELETE /`api/runs/:run_id`
6696///
6697/// Removes all on-disk artifacts for the run and purges the run from the
6698/// in-memory cache and the persisted registry. Returns 204 on success.
6699async fn delete_run_handler(
6700    State(state): State<AppState>,
6701    AxumPath(run_id): AxumPath<String>,
6702) -> Response {
6703    // Resolve output directory.
6704    let output_dir = {
6705        let mut cache = state.artifacts.lock().await;
6706        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6707        cache.remove(&run_id);
6708        dir
6709    };
6710    let output_dir = if let Some(d) = output_dir {
6711        d
6712    } else {
6713        let reg = state.registry.lock().await;
6714        reg.find_by_run_id(&run_id)
6715            .map(|e| recover_artifacts_from_registry(e).output_dir)
6716            .unwrap_or_default()
6717    };
6718
6719    // Remove from persisted registry.
6720    {
6721        let mut reg = state.registry.lock().await;
6722        reg.entries.retain(|e| e.run_id != run_id);
6723        let _ = reg.save(&state.registry_path);
6724    }
6725
6726    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6727    // a prior delete may have already removed the directory.
6728    if output_dir.exists() {
6729        match tokio::fs::remove_dir_all(&output_dir).await {
6730            Ok(()) => {}
6731            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6732            Err(e) => {
6733                return (
6734                    StatusCode::INTERNAL_SERVER_ERROR,
6735                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6736                )
6737                    .into_response();
6738            }
6739        }
6740    }
6741
6742    StatusCode::NO_CONTENT.into_response()
6743}
6744
6745/// POST /api/runs/cleanup
6746///
6747/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6748/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6749async fn cleanup_runs_handler(
6750    State(state): State<AppState>,
6751    Json(body): Json<serde_json::Value>,
6752) -> Response {
6753    let days = body
6754        .get("older_than_days")
6755        .and_then(serde_json::Value::as_u64)
6756        .unwrap_or(30)
6757        .max(1);
6758
6759    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6760
6761    // Collect expired entries from the registry.
6762    let expired: Vec<(String, PathBuf)> = {
6763        let reg = state.registry.lock().await;
6764        reg.entries
6765            .iter()
6766            .filter(|e| e.timestamp_utc < cutoff)
6767            .map(|e| {
6768                let arts = recover_artifacts_from_registry(e);
6769                (e.run_id.clone(), arts.output_dir)
6770            })
6771            .collect()
6772    };
6773
6774    let mut deleted = 0usize;
6775    for (run_id, output_dir) in &expired {
6776        // Remove from in-memory cache.
6777        state.artifacts.lock().await.remove(run_id);
6778        // Delete on-disk artifacts (non-fatal if already gone).
6779        if output_dir.exists() {
6780            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6781                eprintln!(
6782                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6783                    output_dir.display()
6784                );
6785                continue;
6786            }
6787        }
6788        deleted += 1;
6789    }
6790
6791    // Purge expired run IDs from the registry in one pass.
6792    let expired_ids: std::collections::HashSet<&str> =
6793        expired.iter().map(|(id, _)| id.as_str()).collect();
6794    {
6795        let mut reg = state.registry.lock().await;
6796        reg.entries
6797            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6798        let _ = reg.save(&state.registry_path);
6799    }
6800
6801    Json(serde_json::json!({ "deleted": deleted })).into_response()
6802}
6803
6804/// Spawns the background auto-cleanup task. Returns a handle so the caller can
6805/// abort it when the policy is updated or disabled.
6806fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6807    tokio::spawn(async move {
6808        loop {
6809            let interval_secs = {
6810                let store = state.cleanup_policy.lock().await;
6811                match &store.policy {
6812                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6813                    _ => break,
6814                }
6815            };
6816            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6817            let n = run_auto_cleanup(&state).await;
6818            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6819        }
6820    })
6821}
6822
6823fn collect_runs_to_delete(
6824    reg: &ScanRegistry,
6825    max_age_days: Option<u32>,
6826    max_run_count: Option<u32>,
6827) -> std::collections::HashSet<String> {
6828    let mut to_delete = std::collections::HashSet::new();
6829    if let Some(days) = max_age_days {
6830        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6831        for e in &reg.entries {
6832            if e.timestamp_utc < cutoff {
6833                to_delete.insert(e.run_id.clone());
6834            }
6835        }
6836    }
6837    if let Some(max_count) = max_run_count {
6838        // entries are sorted newest-first; skip the ones we keep
6839        for e in reg.entries.iter().skip(max_count as usize) {
6840            to_delete.insert(e.run_id.clone());
6841        }
6842    }
6843    to_delete
6844}
6845
6846async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6847    let output_dir = {
6848        let mut cache = state.artifacts.lock().await;
6849        let d = cache.get(run_id).map(|a| a.output_dir.clone());
6850        cache.remove(run_id);
6851        d
6852    };
6853    let output_dir = if let Some(d) = output_dir {
6854        d
6855    } else {
6856        let reg = state.registry.lock().await;
6857        reg.find_by_run_id(run_id)
6858            .map(|e| recover_artifacts_from_registry(e).output_dir)
6859            .unwrap_or_default()
6860    };
6861    if output_dir.exists() {
6862        let _ = tokio::fs::remove_dir_all(&output_dir).await;
6863    }
6864}
6865
6866/// Core cleanup logic shared by the background task and the "Run Now" handler.
6867/// Applies both the age limit and the count limit, then updates `last_run_at`.
6868/// Returns the number of runs deleted.
6869async fn run_auto_cleanup(state: &AppState) -> u32 {
6870    let (max_age_days, max_run_count) = {
6871        let store = state.cleanup_policy.lock().await;
6872        match &store.policy {
6873            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6874            _ => return 0,
6875        }
6876    };
6877
6878    let to_delete = {
6879        let reg = state.registry.lock().await;
6880        collect_runs_to_delete(&reg, max_age_days, max_run_count)
6881    };
6882
6883    for run_id in &to_delete {
6884        delete_run_artifacts(state, run_id).await;
6885    }
6886
6887    // Purge from registry.
6888    if !to_delete.is_empty() {
6889        let mut reg = state.registry.lock().await;
6890        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6891        let _ = reg.save(&state.registry_path);
6892    }
6893
6894    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6895    {
6896        let mut store = state.cleanup_policy.lock().await;
6897        store.last_run_at = Some(chrono::Utc::now());
6898        store.last_run_deleted = Some(deleted);
6899        let _ = store.save(&state.cleanup_policy_path);
6900    }
6901    deleted
6902}
6903
6904// ── Auto-cleanup policy API ───────────────────────────────────────────────────
6905
6906/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
6907async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6908    let store = state.cleanup_policy.lock().await;
6909    Json(serde_json::json!({
6910        "policy": store.policy,
6911        "last_run_at": store.last_run_at,
6912        "last_run_deleted": store.last_run_deleted,
6913    }))
6914    .into_response()
6915}
6916
6917/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
6918async fn api_save_cleanup_policy(
6919    State(state): State<AppState>,
6920    Json(body): Json<CleanupPolicy>,
6921) -> Response {
6922    // Abort any running task so the new interval takes effect immediately.
6923    {
6924        let mut handle = state.cleanup_task_handle.lock().await;
6925        if let Some(h) = handle.take() {
6926            h.abort();
6927        }
6928    }
6929    {
6930        let mut store = state.cleanup_policy.lock().await;
6931        store.policy = Some(body.clone());
6932        if let Err(e) = store.save(&state.cleanup_policy_path) {
6933            return (
6934                StatusCode::INTERNAL_SERVER_ERROR,
6935                Json(serde_json::json!({"error": e.to_string()})),
6936            )
6937                .into_response();
6938        }
6939    }
6940    if body.enabled {
6941        let handle = spawn_cleanup_policy_task(state.clone());
6942        *state.cleanup_task_handle.lock().await = Some(handle);
6943    }
6944    StatusCode::NO_CONTENT.into_response()
6945}
6946
6947/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
6948async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6949    let deleted = run_auto_cleanup(&state).await;
6950    Json(serde_json::json!({ "deleted": deleted })).into_response()
6951}
6952
6953/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
6954async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6955    {
6956        let mut handle = state.cleanup_task_handle.lock().await;
6957        if let Some(h) = handle.take() {
6958            h.abort();
6959        }
6960    }
6961    {
6962        let mut store = state.cleanup_policy.lock().await;
6963        store.policy = None;
6964        let _ = store.save(&state.cleanup_policy_path);
6965    }
6966    StatusCode::NO_CONTENT.into_response()
6967}
6968
6969/// Serve the HTML artifact for a run — view or download.
6970/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
6971/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
6972/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
6973/// Only called for browser views; downloads keep the self-contained inline version.
6974fn swap_inline_chart_js_for_static(html: String) -> String {
6975    let Some(head_end) = html.find("</head>") else {
6976        return html;
6977    };
6978    let Some(script_start) = html[..head_end].rfind("<script") else {
6979        return html;
6980    };
6981    let Some(close_offset) = html[script_start..].find("</script>") else {
6982        return html;
6983    };
6984    let block_end = script_start + close_offset + "</script>".len();
6985    format!(
6986        "{}<script src=\"/static/chart-report.js\"></script>{}",
6987        &html[..script_start],
6988        &html[block_end..]
6989    )
6990}
6991
6992/// current-request Content-Security-Policy nonce check.
6993fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6994    // Find the first nonce value that was baked in at render time.
6995    let Some(start) = html.find("nonce=\"") else {
6996        // Reports generated before nonce support was added have bare <style> and <script>
6997        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
6998        // the inline blocks — without it the browser blocks all CSS and JS.
6999        return html
7000            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7001            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7002    };
7003    let value_start = start + 7; // len(r#"nonce=""#) == 7
7004    let Some(end_offset) = html[value_start..].find('"') else {
7005        return html.to_owned();
7006    };
7007    let old_nonce = &html[value_start..value_start + end_offset];
7008    html.replace(
7009        &format!("nonce=\"{old_nonce}\""),
7010        &format!("nonce=\"{new_nonce}\""),
7011    )
7012}
7013
7014fn serve_html_artifact(
7015    path: &Path,
7016    wants_download: bool,
7017    csp_nonce: &str,
7018    run_id: &str,
7019    server_mode: bool,
7020) -> Response {
7021    match fs::read_to_string(path) {
7022        Ok(raw) => {
7023            // Patch the saved nonce so inline styles/scripts pass CSP.
7024            let content = patch_html_nonce(&raw, csp_nonce);
7025            if wants_download {
7026                // Keep the self-contained inline version for downloads (opened as file://).
7027                (
7028                    [
7029                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7030                        (
7031                            header::CONTENT_DISPOSITION,
7032                            "attachment; filename=report.html",
7033                        ),
7034                    ],
7035                    content,
7036                )
7037                    .into_response()
7038            } else {
7039                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
7040                // browser caches it after the first view; the HTML response also shrinks.
7041                Html(swap_inline_chart_js_for_static(content)).into_response()
7042            }
7043        }
7044        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7045            let filename = path.file_name().map_or_else(
7046                || "report.html".to_string(),
7047                |n| n.to_string_lossy().into_owned(),
7048            );
7049            let html = LocateFileTemplate {
7050                run_id: run_id.to_owned(),
7051                artifact_type: "html".to_string(),
7052                expected_filename: filename,
7053                server_mode,
7054                csp_nonce: csp_nonce.to_owned(),
7055                version: env!("CARGO_PKG_VERSION"),
7056            }
7057            .render()
7058            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7059            (StatusCode::NOT_FOUND, Html(html)).into_response()
7060        }
7061        Err(err) => {
7062            let filename = path.file_name().map_or_else(
7063                || "report.html".to_string(),
7064                |n| n.to_string_lossy().into_owned(),
7065            );
7066            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7067            let html = ErrorTemplate {
7068                message: msg,
7069                last_report_url: Some("/view-reports".to_string()),
7070                last_report_label: Some("View Reports".to_string()),
7071                run_id: None,
7072                error_code: Some(404),
7073                csp_nonce: csp_nonce.to_owned(),
7074                version: env!("CARGO_PKG_VERSION"),
7075            }
7076            .render()
7077            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7078            (StatusCode::NOT_FOUND, Html(html)).into_response()
7079        }
7080    }
7081}
7082
7083/// Serve the PDF artifact for a run — inline or download.
7084fn serve_pdf_artifact(
7085    path: &Path,
7086    report_title: &str,
7087    run_id: &str,
7088    wants_download: bool,
7089    csp_nonce: &str,
7090) -> Response {
7091    match fs::read(path) {
7092        Ok(bytes) => {
7093            let filename = build_pdf_filename(report_title, run_id);
7094            let disposition = if wants_download {
7095                format!("attachment; filename=\"{filename}\"")
7096            } else {
7097                format!("inline; filename=\"{filename}\"")
7098            };
7099            (
7100                [
7101                    (header::CONTENT_TYPE, "application/pdf".to_string()),
7102                    (header::CONTENT_DISPOSITION, disposition),
7103                ],
7104                bytes,
7105            )
7106                .into_response()
7107        }
7108        Err(err) => {
7109            let filename = path.file_name().map_or_else(
7110                || "report.pdf".to_string(),
7111                |n| n.to_string_lossy().into_owned(),
7112            );
7113            let msg = format!(
7114                "PDF report '{filename}' could not be read.\n\n\
7115                 Error: {err}\n\n\
7116                 If you moved or renamed the output folder, the stored path is now stale. \
7117                 Use 'Open PDF folder' from the results page to browse the output directory."
7118            );
7119            let html = ErrorTemplate {
7120                message: msg,
7121                last_report_url: Some("/view-reports".to_string()),
7122                last_report_label: Some("View Reports".to_string()),
7123                run_id: Some(run_id.to_owned()),
7124                error_code: Some(404),
7125                csp_nonce: csp_nonce.to_owned(),
7126                version: env!("CARGO_PKG_VERSION"),
7127            }
7128            .render()
7129            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7130            (StatusCode::NOT_FOUND, Html(html)).into_response()
7131        }
7132    }
7133}
7134
7135/// Serve the JSON artifact for a run — view or download.
7136fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7137    match fs::read(path) {
7138        Ok(bytes) => {
7139            if wants_download {
7140                (
7141                    [
7142                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7143                        (
7144                            header::CONTENT_DISPOSITION,
7145                            "attachment; filename=result.json",
7146                        ),
7147                    ],
7148                    bytes,
7149                )
7150                    .into_response()
7151            } else {
7152                (
7153                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7154                    bytes,
7155                )
7156                    .into_response()
7157            }
7158        }
7159        Err(err) => {
7160            let filename = path.file_name().map_or_else(
7161                || "result.json".to_string(),
7162                |n| n.to_string_lossy().into_owned(),
7163            );
7164            let msg = format!(
7165                "JSON result '{filename}' could not be read.\n\n\
7166                 Error: {err}\n\n\
7167                 If you moved or renamed the output folder, the stored path is now stale. \
7168                 Use 'Open JSON folder' from the results page to browse the output directory."
7169            );
7170            let html = ErrorTemplate {
7171                message: msg,
7172                last_report_url: Some("/view-reports".to_string()),
7173                last_report_label: Some("View Reports".to_string()),
7174                run_id: None,
7175                error_code: Some(404),
7176                csp_nonce: csp_nonce.to_owned(),
7177                version: env!("CARGO_PKG_VERSION"),
7178            }
7179            .render()
7180            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7181            (StatusCode::NOT_FOUND, Html(html)).into_response()
7182        }
7183    }
7184}
7185
7186/// Recover a `RunArtifacts` from the persisted registry for a run ID.
7187fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7188    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
7189    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
7190    let output_dir = entry
7191        .html_path
7192        .as_ref()
7193        .or(entry.json_path.as_ref())
7194        .or(entry.pdf_path.as_ref())
7195        .or(entry.csv_path.as_ref())
7196        .or(entry.xlsx_path.as_ref())
7197        .and_then(|p| {
7198            let parent = p.parent()?;
7199            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7200            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
7201            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7202                parent.parent().map(PathBuf::from)
7203            } else {
7204                Some(parent.to_path_buf())
7205            }
7206        })
7207        .unwrap_or_default();
7208    // Recover pdf_path: use the persisted one, or look for report.pdf
7209    // adjacent to html/json if only the old entries lack it.
7210    let pdf_path = entry.pdf_path.clone().or_else(|| {
7211        let candidate = output_dir.join("report.pdf");
7212        candidate.exists().then_some(candidate)
7213    });
7214    // csv_path / xlsx_path: persisted paths take precedence; fall back to
7215    // scanning the run directory for files matching the expected patterns so
7216    // that runs created before this feature still surface their artifacts.
7217    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7218        // Check excel/ subfolder (new layout) then root (old layout).
7219        for dir in &[output_dir.join("excel"), output_dir.clone()] {
7220            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7221                entries
7222                    .filter_map(std::result::Result::ok)
7223                    .find(|e| {
7224                        let n = e.file_name();
7225                        let n = n.to_string_lossy();
7226                        n.starts_with("report_") && n.ends_with(ext)
7227                    })
7228                    .map(|e| e.path())
7229            }) {
7230                return Some(p);
7231            }
7232        }
7233        None
7234    };
7235
7236    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7237    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7238    RunArtifacts {
7239        output_dir: output_dir.clone(),
7240        html_path: entry.html_path.clone(),
7241        pdf_path,
7242        json_path: entry.json_path.clone(),
7243        csv_path,
7244        xlsx_path,
7245        scan_config_path: find_scan_config_in_dir(&output_dir),
7246        report_title: entry.project_label.clone(),
7247        result_context: RunResultContext::default(),
7248    }
7249}
7250
7251#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
7252async fn resolve_artifact_set(
7253    state: &AppState,
7254    run_id: &str,
7255    csp_nonce: &str,
7256) -> Result<RunArtifacts, Response> {
7257    let cached = state.artifacts.lock().await.get(run_id).cloned();
7258    if let Some(a) = cached {
7259        return Ok(a);
7260    }
7261    let reg = state.registry.lock().await;
7262    if let Some(entry) = reg.find_by_run_id(run_id) {
7263        return Ok(recover_artifacts_from_registry(entry));
7264    }
7265    drop(reg);
7266    let short_id = &run_id[..run_id.len().min(8)];
7267    let hint = if matches!(
7268        run_id,
7269        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7270    ) {
7271        format!(
7272            " The URL format appears to be reversed \u{2014} \
7273             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7274             Use the View Reports page to navigate to your scan."
7275        )
7276    } else {
7277        " The report may have been deleted or the report directory moved. \
7278         Use View Reports to browse your scan history."
7279            .to_string()
7280    };
7281    let error_html = ErrorTemplate {
7282        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7283        last_report_url: Some("/view-reports".to_string()),
7284        last_report_label: Some("View Reports".to_string()),
7285        run_id: None,
7286        error_code: Some(404),
7287        csp_nonce: csp_nonce.to_owned(),
7288        version: env!("CARGO_PKG_VERSION"),
7289    }
7290    .render()
7291    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7292    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7293}
7294
7295/// Return the path to a run's PDF, queuing background generation when it is missing.
7296///
7297/// Returns `Ok(path)` when the PDF is known (it may still be generating).
7298/// Returns `Err(response)` when there is no JSON source to regenerate from.
7299async fn resolve_or_queue_pdf(
7300    state: &AppState,
7301    pdf_path: Option<PathBuf>,
7302    json_path: Option<PathBuf>,
7303    output_dir: PathBuf,
7304    run_id: &str,
7305    report_title: &str,
7306    csp_nonce: &str,
7307) -> Result<PathBuf, Response> {
7308    if let Some(p) = pdf_path {
7309        return Ok(p);
7310    }
7311    let Some(json_src) = json_path.filter(|p| p.exists()) else {
7312        let msg = "PDF report was not generated for this run. \
7313                   Re-run the analysis with PDF output enabled."
7314            .to_string();
7315        let html = ErrorTemplate {
7316            message: msg,
7317            last_report_url: Some(format!("/runs/html/{run_id}")),
7318            last_report_label: Some("View HTML Report".to_string()),
7319            run_id: Some(run_id.to_string()),
7320            error_code: Some(404),
7321            csp_nonce: csp_nonce.to_string(),
7322            version: env!("CARGO_PKG_VERSION"),
7323        }
7324        .render()
7325        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7326        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7327    };
7328    let pdf_filename = build_pdf_filename(report_title, run_id);
7329    let pdf_dest = output_dir.join(&pdf_filename);
7330    if !pdf_dest.exists() {
7331        // Record the pending path so concurrent requests show the spinner.
7332        {
7333            let mut map = state.artifacts.lock().await;
7334            if let Some(entry) = map.get_mut(run_id) {
7335                entry.pdf_path = Some(pdf_dest.clone());
7336            }
7337        }
7338        {
7339            let mut reg = state.registry.lock().await;
7340            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7341                e.pdf_path = Some(pdf_dest.clone());
7342            }
7343            let _ = reg.save(&state.registry_path);
7344        }
7345        spawn_native_pdf_background(
7346            json_src,
7347            pdf_dest.clone(),
7348            run_id.to_string(),
7349            state.artifacts.clone(),
7350        );
7351    }
7352    Ok(pdf_dest)
7353}
7354
7355/// Self-refreshing "please wait" page shown while the background PDF task is still running.
7356fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7357    let html = format!(
7358                    "<!doctype html><html lang=\"en\"><head>\
7359                     <meta charset=utf-8>\
7360                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7361                     <meta http-equiv=\"refresh\" content=\"5\">\
7362                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
7363                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7364                     <style nonce=\"{csp_nonce}\">\
7365                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7366                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7367                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7368                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7369                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7370                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7371                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7372                     background:var(--bg);color:var(--text);}}\
7373                     .top-nav{{position:sticky;top:0;z-index:30;\
7374                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7375                     border-bottom:1px solid rgba(255,255,255,0.12);\
7376                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7377                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7378                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
7379                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7380                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7381                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7382                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7383                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7384                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7385                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7386                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7387                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7388                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7389                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7390                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7391                     justify-content:center;min-height:38px;border-radius:999px;\
7392                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7393                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7394                     .theme-toggle .icon-sun{{display:none;}}\
7395                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7396                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7397                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7398                     display:flex;align-items:center;justify-content:center;\
7399                     min-height:calc(100vh - 56px);}}\
7400                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7401                     .panel{{background:var(--surface);border:1px solid var(--line);\
7402                     border-radius:var(--radius);box-shadow:var(--shadow);\
7403                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7404                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
7405                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
7406                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7407                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7408                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7409                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7410                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7411                     min-height:42px;padding:0 20px;border-radius:14px;\
7412                     border:1px solid var(--line-strong);text-decoration:none;\
7413                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7414                     .back-link:hover{{background:var(--line);}}\
7415                     </style></head>\
7416                     <body>\
7417                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7418                       <a class=\"brand\" href=\"/\">\
7419                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7420                         <div class=\"brand-copy\">\
7421                           <div class=\"brand-title\">OxideSLOC</div>\
7422                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7423                         </div>\
7424                       </a>\
7425                       <div class=\"nav-right\">\
7426                         <a class=\"nav-pill\" href=\"/\">Home</a>\
7427                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7428                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7429                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7430                           <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>\
7431                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7432                           <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>\
7433                         </button>\
7434                       </div>\
7435                     </div></div>\
7436                     <div class=\"page\"><div class=\"panel\">\
7437                       <div class=\"spin-ring\"></div>\
7438                       <h1>Generating PDF\u{2026}</h1>\
7439                       <p>The PDF is being generated from the scan results.<br>\
7440                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7441                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7442                     </div></div>\
7443                     <script nonce=\"{csp_nonce}\">\
7444                     (function(){{\
7445                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7446                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7447                       var t=document.getElementById(\"theme-toggle\");\
7448                       if(t)t.addEventListener(\"click\",function(){{\
7449                         var d=b.classList.toggle(\"dark-theme\");\
7450                         localStorage.setItem(k,d?\"dark\":\"light\");\
7451                       }});\
7452                     }})();\
7453                     </script>\
7454                     </body></html>"
7455    );
7456    Html(html).into_response()
7457}
7458
7459/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7460fn render_error_artifact_html(
7461    message: String,
7462    last_report_url: Option<String>,
7463    last_report_label: Option<String>,
7464    run_id: Option<String>,
7465    error_code: Option<u16>,
7466    csp_nonce: &str,
7467) -> String {
7468    ErrorTemplate {
7469        message,
7470        last_report_url,
7471        last_report_label,
7472        run_id,
7473        error_code,
7474        csp_nonce: csp_nonce.to_owned(),
7475        version: env!("CARGO_PKG_VERSION"),
7476    }
7477    .render()
7478    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7479}
7480
7481/// Read a file and serve it as an attachment download.
7482fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7483    fs::read(path).map_or_else(
7484        |_| StatusCode::NOT_FOUND.into_response(),
7485        |bytes| {
7486            let filename = path.file_name().map_or_else(
7487                || fallback_filename.to_string(),
7488                |n| n.to_string_lossy().into_owned(),
7489            );
7490            (
7491                [
7492                    (header::CONTENT_TYPE, content_type.to_string()),
7493                    (
7494                        header::CONTENT_DISPOSITION,
7495                        format!("attachment; filename=\"{filename}\""),
7496                    ),
7497                ],
7498                bytes,
7499            )
7500                .into_response()
7501        },
7502    )
7503}
7504
7505fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7506    let Some(path) = csv_path else {
7507        let html = render_error_artifact_html(
7508            "CSV report was not generated for this run, or was not recorded in \
7509             the scan registry."
7510                .to_string(),
7511            Some(format!("/runs/html/{run_id}")),
7512            Some("View HTML Report".to_string()),
7513            Some(run_id.to_string()),
7514            Some(404),
7515            csp_nonce,
7516        );
7517        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7518    };
7519    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7520}
7521
7522fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7523    let Some(path) = xlsx_path else {
7524        let html = render_error_artifact_html(
7525            "Excel report was not generated for this run, or was not recorded in \
7526             the scan registry."
7527                .to_string(),
7528            Some(format!("/runs/html/{run_id}")),
7529            Some("View HTML Report".to_string()),
7530            Some(run_id.to_string()),
7531            Some(404),
7532            csp_nonce,
7533        );
7534        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7535    };
7536    serve_binary_download(
7537        &path,
7538        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7539        "report.xlsx",
7540    )
7541}
7542
7543fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7544    let path = artifact_set
7545        .scan_config_path
7546        .as_deref()
7547        .map(std::path::Path::to_path_buf)
7548        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7549        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7550    fs::read(&path).map_or_else(
7551        |_| StatusCode::NOT_FOUND.into_response(),
7552        |bytes| {
7553            (
7554                [
7555                    (
7556                        header::CONTENT_TYPE,
7557                        "application/json; charset=utf-8".to_string(),
7558                    ),
7559                    (
7560                        header::CONTENT_DISPOSITION,
7561                        "attachment; filename=\"scan-config.json\"".to_string(),
7562                    ),
7563                ],
7564                bytes,
7565            )
7566                .into_response()
7567        },
7568    )
7569}
7570
7571/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7572/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7573/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7574/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7575async fn serve_submodule_pdf_arm(
7576    artifact: &str,
7577    artifact_set: RunArtifacts,
7578    wants_download: bool,
7579    run_id: &str,
7580    csp_nonce: &str,
7581) -> Response {
7582    // "sub_benchmark_pdf" → base = "sub_benchmark"
7583    let base = artifact.trim_end_matches("_pdf");
7584    let sub_dir = artifact_set.output_dir.join("submodules");
7585    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7586
7587    if !pdf_path.exists() {
7588        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7589        let derived_safe = base.trim_start_matches("sub_");
7590        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7591            let parent_run = read_json(jp).ok()?;
7592            let sub = parent_run
7593                .submodule_summaries
7594                .iter()
7595                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7596                .clone();
7597            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7598            Some((parent_run, sub, parent_path))
7599        });
7600
7601        if let Some((parent_run, sub, parent_path)) = rebuilt {
7602            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7603            let pp = pdf_path.clone();
7604            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7605        }
7606    }
7607
7608    if !pdf_path.exists() {
7609        let html = render_error_artifact_html(
7610            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7611             enabled."
7612                .to_string(),
7613            Some("/view-reports".to_string()),
7614            Some("View Reports".to_string()),
7615            Some(run_id.to_string()),
7616            Some(404),
7617            csp_nonce,
7618        );
7619        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7620    }
7621
7622    serve_pdf_artifact(
7623        &pdf_path,
7624        &artifact_set.report_title,
7625        run_id,
7626        wants_download,
7627        csp_nonce,
7628    )
7629}
7630
7631fn serve_submodule_arm(
7632    artifact: &str,
7633    artifact_set: &RunArtifacts,
7634    wants_download: bool,
7635    csp_nonce: &str,
7636    run_id: &str,
7637    server_mode: bool,
7638) -> Response {
7639    if artifact.len() > 128
7640        || !artifact
7641            .chars()
7642            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7643    {
7644        return StatusCode::BAD_REQUEST.into_response();
7645    }
7646    let filename = format!("{artifact}.html");
7647    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7648    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7649    let path = if new_layout.exists() {
7650        new_layout
7651    } else {
7652        artifact_set.output_dir.join(&filename)
7653    };
7654    if !path.exists() {
7655        let html = render_error_artifact_html(
7656            format!(
7657                "Sub-report '{artifact}' was not found in the run directory.\n\
7658                 Re-run the analysis with 'Detect and separate git submodules' \
7659                 and HTML output enabled."
7660            ),
7661            Some("/view-reports".to_string()),
7662            Some("View Reports".to_string()),
7663            Some(run_id.to_string()),
7664            Some(404),
7665            csp_nonce,
7666        );
7667        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7668    }
7669    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7670}
7671
7672async fn serve_pdf_arm(
7673    state: &AppState,
7674    artifact_set: RunArtifacts,
7675    wants_download: bool,
7676    run_id: &str,
7677    csp_nonce: &str,
7678) -> Response {
7679    let report_title = artifact_set.report_title.clone();
7680    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7681    let stale_html_name = artifact_set
7682        .html_path
7683        .as_deref()
7684        .and_then(|p| p.file_name())
7685        .map(|n| n.to_string_lossy().into_owned());
7686    let path = match resolve_or_queue_pdf(
7687        state,
7688        artifact_set.pdf_path,
7689        artifact_set.json_path.clone(),
7690        artifact_set.output_dir.clone(),
7691        run_id,
7692        &report_title,
7693        csp_nonce,
7694    )
7695    .await
7696    {
7697        Ok(p) => p,
7698        Err(r) => return r,
7699    };
7700    if !path.exists() {
7701        // Distinguish a stale registry path (folder moved) from an in-progress
7702        // background generation. Only show the locate page when the PDF was
7703        // already recorded in the registry but the file is now missing.
7704        if had_pdf_in_registry {
7705            if let Some(expected_filename) = stale_html_name {
7706                let html = LocateFileTemplate {
7707                    run_id: run_id.to_string(),
7708                    artifact_type: "pdf".to_string(),
7709                    expected_filename,
7710                    server_mode: state.server_mode,
7711                    csp_nonce: csp_nonce.to_string(),
7712                    version: env!("CARGO_PKG_VERSION"),
7713                }
7714                .render()
7715                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7716                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7717            }
7718        }
7719        return pdf_generating_response(run_id, csp_nonce);
7720    }
7721    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7722}
7723
7724async fn artifact_handler(
7725    State(state): State<AppState>,
7726    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7727    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7728    Query(query): Query<ArtifactQuery>,
7729) -> Response {
7730    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7731        Ok(a) => a,
7732        Err(r) => return r,
7733    };
7734
7735    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7736
7737    match artifact.as_str() {
7738        "html" => {
7739            let Some(path) = artifact_set.html_path else {
7740                return StatusCode::NOT_FOUND.into_response();
7741            };
7742            serve_html_artifact(
7743                &path,
7744                wants_download,
7745                &csp_nonce,
7746                &run_id,
7747                state.server_mode,
7748            )
7749        }
7750        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7751        "json" => {
7752            let Some(path) = artifact_set.json_path else {
7753                let html = render_error_artifact_html(
7754                    "JSON result was not generated for this run, or was not recorded in \
7755                     the scan registry. Re-run the analysis with JSON output enabled."
7756                        .to_string(),
7757                    Some("/view-reports".to_string()),
7758                    Some("View Reports".to_string()),
7759                    Some(run_id.clone()),
7760                    Some(404),
7761                    &csp_nonce,
7762                );
7763                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7764            };
7765            serve_json_artifact(&path, wants_download, &csp_nonce)
7766        }
7767        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7768        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7769        "scan-config" => serve_scan_config_arm(&artifact_set),
7770        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7771            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7772                .await
7773        }
7774        _ if artifact.starts_with("sub_") => serve_submodule_arm(
7775            &artifact,
7776            &artifact_set,
7777            wants_download,
7778            &csp_nonce,
7779            &run_id,
7780            state.server_mode,
7781        ),
7782        _ => StatusCode::NOT_FOUND.into_response(),
7783    }
7784}
7785
7786// ── History ───────────────────────────────────────────────────────────────────
7787
7788struct SubmoduleLinkRow {
7789    name: String,
7790    url: String,
7791}
7792
7793struct HistoryEntryRow {
7794    run_id: String,
7795    run_id_short: String,
7796    timestamp: String,
7797    timestamp_utc_ms: i64,
7798    project_label: String,
7799    project_path: String,
7800    files_analyzed: u64,
7801    files_skipped: u64,
7802    code_lines: u64,
7803    comment_lines: u64,
7804    blank_lines: u64,
7805    total_physical_lines: u64,
7806    functions: u64,
7807    classes: u64,
7808    variables: u64,
7809    imports: u64,
7810    test_count: u64,
7811    git_branch: String,
7812    git_commit: String,
7813    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
7814    git_commit_long: String,
7815    has_html: bool,
7816    has_json: bool,
7817    has_pdf: bool,
7818    submodule_links: Vec<SubmoduleLinkRow>,
7819    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
7820    submodule_names_csv: String,
7821}
7822
7823/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
7824fn nth_weekday_of_month(
7825    year: i32,
7826    month: u32,
7827    weekday: chrono::Weekday,
7828    n: u32,
7829) -> chrono::NaiveDate {
7830    use chrono::Datelike;
7831    let mut count = 0u32;
7832    let mut day = 1u32;
7833    loop {
7834        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7835        if d.weekday() == weekday {
7836            count += 1;
7837            if count == n {
7838                return d;
7839            }
7840        }
7841        day += 1;
7842    }
7843}
7844
7845/// Returns true if `dt` falls within US Pacific Daylight Time.
7846/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
7847/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
7848fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7849    use chrono::{Datelike, TimeZone};
7850    let year = dt.year();
7851    let dst_start = chrono::Utc.from_utc_datetime(
7852        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7853            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7854    );
7855    let dst_end = chrono::Utc.from_utc_datetime(
7856        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7857            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7858    );
7859    dt >= dst_start && dt < dst_end
7860}
7861
7862fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7863    if is_pacific_dst(dt) {
7864        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7865            .format("%Y-%m-%d %H:%M PDT")
7866            .to_string()
7867    } else {
7868        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7869            .format("%Y-%m-%d %H:%M PST")
7870            .to_string()
7871    }
7872}
7873
7874/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
7875fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7876    let (offset, tz) = if is_pacific_dst(dt) {
7877        (
7878            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7879            "PDT",
7880        )
7881    } else {
7882        (
7883            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7884            "PST",
7885        )
7886    };
7887    format!(
7888        "{} {tz}",
7889        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7890    )
7891}
7892
7893fn fmt_git_date(iso: &str) -> Option<String> {
7894    chrono::DateTime::parse_from_rfc3339(iso)
7895        .ok()
7896        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7897}
7898
7899/// Recover the full-length commit SHA for a registry entry whose stored record
7900/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
7901///
7902/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
7903/// is serialized after the per-file records, near the end of the file. We read a
7904/// bounded tail and pick the `git_commit_long` value whose hash begins with the
7905/// known short SHA — this disambiguates the super-repo commit from any submodule
7906/// commits that also appear. Returns `None` if the file is unreadable or no match.
7907fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7908    use std::io::{Read, Seek, SeekFrom};
7909    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
7910    if short.is_empty() {
7911        return None;
7912    }
7913    let len = std::fs::metadata(path).ok()?.len();
7914    let start = len.saturating_sub(TAIL);
7915    let mut file = std::fs::File::open(path).ok()?;
7916    file.seek(SeekFrom::Start(start)).ok()?;
7917    let mut buf = Vec::new();
7918    file.read_to_end(&mut buf).ok()?;
7919    let text = String::from_utf8_lossy(&buf);
7920    let short_lower = short.to_ascii_lowercase();
7921    let key = "\"git_commit_long\"";
7922    let mut found: Option<String> = None;
7923    let mut cursor = 0usize;
7924    while let Some(idx) = text[cursor..].find(key) {
7925        let after_key = cursor + idx + key.len();
7926        cursor = after_key;
7927        let rest = &text[after_key..];
7928        let Some(colon) = rest.find(':') else { break };
7929        let value_region = rest[colon + 1..].trim_start();
7930        // Skip `null` (or any non-string) values without consuming the next field.
7931        if let Some(open) = value_region.strip_prefix('"') {
7932            if let Some(close) = open.find('"') {
7933                let val = &open[..close];
7934                if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7935                    found = Some(val.to_string());
7936                }
7937            }
7938        }
7939    }
7940    found
7941}
7942
7943fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7944    reg.entries
7945        .iter()
7946        .map(|e| {
7947            let submodule_links = {
7948                let mut links: Vec<SubmoduleLinkRow> = vec![];
7949                let sub_dir = e
7950                    .html_path
7951                    .as_ref()
7952                    .and_then(|p| p.parent())
7953                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7954                if let Some(dir) = sub_dir {
7955                    if let Ok(rd) = std::fs::read_dir(dir) {
7956                        for entry_res in rd.flatten() {
7957                            let fname = entry_res.file_name();
7958                            let fname_str = fname.to_string_lossy();
7959                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7960                                let stem = &fname_str[..fname_str.len() - 5];
7961                                let display = stem[4..].replace('-', " ");
7962                                links.push(SubmoduleLinkRow {
7963                                    name: display,
7964                                    url: format!("/runs/{stem}/{}", e.run_id),
7965                                });
7966                            }
7967                        }
7968                    }
7969                }
7970                links.sort_by(|a, b| a.name.cmp(&b.name));
7971                links
7972            };
7973            let submodule_names_csv = submodule_links
7974                .iter()
7975                .map(|l| l.name.as_str())
7976                .collect::<Vec<_>>()
7977                .join(",");
7978            HistoryEntryRow {
7979                run_id: e.run_id.clone(),
7980                run_id_short: e
7981                    .run_id
7982                    .split('-')
7983                    .next_back()
7984                    .unwrap_or(&e.run_id)
7985                    .chars()
7986                    .take(7)
7987                    .collect(),
7988                timestamp: fmt_la_time(e.timestamp_utc),
7989                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7990                project_label: e.project_label.clone(),
7991                project_path: e
7992                    .input_roots
7993                    .first()
7994                    .map(|s| sanitize_path_str(s))
7995                    .unwrap_or_default(),
7996                files_analyzed: e.summary.files_analyzed,
7997                files_skipped: e.summary.files_skipped,
7998                code_lines: e.summary.code_lines,
7999                comment_lines: e.summary.comment_lines,
8000                blank_lines: e.summary.blank_lines,
8001                total_physical_lines: e.summary.total_physical_lines,
8002                functions: e.summary.functions,
8003                classes: e.summary.classes,
8004                variables: e.summary.variables,
8005                imports: e.summary.imports,
8006                test_count: e.summary.test_count,
8007                git_branch: e.git_branch.clone().unwrap_or_default(),
8008                git_commit: e.git_commit.clone().unwrap_or_default(),
8009                git_commit_long: {
8010                    let short = e.git_commit.clone().unwrap_or_default();
8011                    e.git_commit_long
8012                        .clone()
8013                        .filter(|s| !s.is_empty())
8014                        .or_else(|| {
8015                            e.json_path
8016                                .as_ref()
8017                                .and_then(|p| extract_long_commit_from_json(p, &short))
8018                        })
8019                        .unwrap_or(short)
8020                },
8021                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8022                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8023                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8024                submodule_links,
8025                submodule_names_csv,
8026            }
8027        })
8028        .collect()
8029}
8030
8031#[derive(Deserialize, Default)]
8032struct HistoryQuery {
8033    linked: Option<String>,
8034    error: Option<String>,
8035}
8036
8037async fn history_handler(
8038    State(state): State<AppState>,
8039    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8040    Query(query): Query<HistoryQuery>,
8041) -> impl IntoResponse {
8042    // Auto-scan all watched directories before rendering so the list stays fresh.
8043    auto_scan_watched_dirs(&state).await;
8044    let watched_dirs: Vec<String> = {
8045        let wd = state.watched_dirs.lock().await;
8046        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8047    };
8048    let mut entries = {
8049        let reg = state.registry.lock().await;
8050        make_history_rows(&reg)
8051    };
8052    entries.retain(|e| e.has_html);
8053    let total_scans = entries.len();
8054    let linked_count = query
8055        .linked
8056        .as_deref()
8057        .and_then(|s| s.parse::<usize>().ok())
8058        .unwrap_or(0);
8059    let browse_error = query.error.filter(|s| !s.is_empty());
8060    let template = HistoryTemplate {
8061        version: env!("CARGO_PKG_VERSION"),
8062        entries,
8063        total_scans,
8064        linked_count,
8065        browse_error,
8066        watched_dirs,
8067        csp_nonce,
8068        server_mode: state.server_mode,
8069    };
8070    Html(
8071        template
8072            .render()
8073            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8074    )
8075    .into_response()
8076}
8077
8078async fn compare_select_handler(
8079    State(state): State<AppState>,
8080    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8081) -> impl IntoResponse {
8082    auto_scan_watched_dirs(&state).await;
8083    let watched_dirs: Vec<String> = {
8084        let wd = state.watched_dirs.lock().await;
8085        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8086    };
8087    let mut entries = {
8088        let reg = state.registry.lock().await;
8089        make_history_rows(&reg)
8090    };
8091    entries.retain(|e| e.has_json);
8092    let total_scans = entries.len();
8093    let template = CompareSelectTemplate {
8094        version: env!("CARGO_PKG_VERSION"),
8095        entries,
8096        total_scans,
8097        watched_dirs,
8098        csp_nonce,
8099        server_mode: state.server_mode,
8100    };
8101    Html(
8102        template
8103            .render()
8104            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8105    )
8106    .into_response()
8107}
8108
8109// ── Compare ───────────────────────────────────────────────────────────────────
8110
8111#[derive(Deserialize, Default)]
8112struct CompareQuery {
8113    a: Option<String>,
8114    b: Option<String>,
8115    /// Optional submodule name to scope the comparison to one submodule.
8116    sub: Option<String>,
8117    /// "super" to exclude all submodule files and show only the super-repo.
8118    scope: Option<String>,
8119}
8120
8121struct CompareFileDeltaRow {
8122    relative_path: String,
8123    language: String,
8124    status: String,
8125    baseline_code: i64,
8126    current_code: i64,
8127    baseline_code_display: String,
8128    current_code_display: String,
8129    code_delta_str: String,
8130    code_delta_class: String,
8131    comment_delta_str: String,
8132    comment_delta_class: String,
8133    total_delta_str: String,
8134    total_delta_class: String,
8135}
8136
8137/// Recompute `summary_totals` from the current `per_file_records` slice.
8138/// Used when `per_file_records` has been narrowed to a submodule subset.
8139fn recompute_summary_from_records(run: &mut AnalysisRun) {
8140    let mut totals = SummaryTotals::default();
8141    for r in &run.per_file_records {
8142        if r.language.is_some() {
8143            totals.files_analyzed += 1;
8144        }
8145        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8146        totals.code_lines += r.effective_counts.code_lines;
8147        totals.comment_lines += r.effective_counts.comment_lines;
8148        totals.blank_lines += r.effective_counts.blank_lines;
8149        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8150        totals.functions += r.raw_line_categories.functions;
8151        totals.classes += r.raw_line_categories.classes;
8152        totals.variables += r.raw_line_categories.variables;
8153        totals.imports += r.raw_line_categories.imports;
8154        totals.test_count += r.raw_line_categories.test_count;
8155        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8156        totals.test_suite_count += r.raw_line_categories.test_suite_count;
8157        if let Some(cov) = &r.coverage {
8158            totals.coverage_lines_found += u64::from(cov.lines_found);
8159            totals.coverage_lines_hit += u64::from(cov.lines_hit);
8160            totals.coverage_functions_found += u64::from(cov.functions_found);
8161            totals.coverage_functions_hit += u64::from(cov.functions_hit);
8162            totals.coverage_branches_found += u64::from(cov.branches_found);
8163            totals.coverage_branches_hit += u64::from(cov.branches_hit);
8164        }
8165    }
8166    totals.files_considered = totals.files_analyzed;
8167    run.summary_totals = totals;
8168}
8169
8170fn fmt_delta(n: i64) -> String {
8171    if n > 0 {
8172        format!("+{n}")
8173    } else {
8174        format!("{n}")
8175    }
8176}
8177
8178fn delta_class(n: i64) -> &'static str {
8179    use std::cmp::Ordering;
8180    match n.cmp(&0) {
8181        Ordering::Greater => "pos",
8182        Ordering::Less => "neg",
8183        Ordering::Equal => "zero",
8184    }
8185}
8186
8187// ratio/percentage display, precision loss acceptable
8188#[allow(clippy::cast_precision_loss)]
8189fn fmt_pct(delta: i64, baseline: u64) -> String {
8190    if baseline == 0 {
8191        return "—".to_string();
8192    }
8193    #[allow(clippy::cast_precision_loss)]
8194    let pct = (delta as f64 / baseline as f64) * 100.0;
8195    if pct > 0.049 {
8196        format!("+{pct:.1}%")
8197    } else if pct < -0.049 {
8198        format!("{pct:.1}%")
8199    } else {
8200        "±0%".to_string()
8201    }
8202}
8203
8204/// Returns (`display_string`, `css_class`) for a numeric change column cell.
8205fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8206    prev.map_or_else(
8207        || ("—".to_string(), "na"),
8208        |p| {
8209            #[allow(clippy::cast_possible_wrap)]
8210            let d = curr as i64 - p as i64;
8211            (fmt_delta(d), delta_class(d))
8212        },
8213    )
8214}
8215
8216#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
8217fn load_scan_for_compare(
8218    json_path: &std::path::Path,
8219    scan_label: &str,
8220    run_id: &str,
8221    server_mode: bool,
8222    compare_url: &str,
8223    csp_nonce: &str,
8224) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8225    match read_json(json_path) {
8226        Ok(r) => Ok(r),
8227        Err(e) => {
8228            if server_mode {
8229                let html = ErrorTemplate {
8230                    message: format!(
8231                        "Could not load {scan_label} scan data. The scan output folder may have \
8232                         been moved, renamed, or deleted. Re-running the analysis will create \
8233                         fresh comparison data."
8234                    ),
8235                    last_report_url: Some("/compare-scans".to_string()),
8236                    last_report_label: Some("Compare Scans".to_string()),
8237                    run_id: Some(run_id.to_owned()),
8238                    error_code: Some(404),
8239                    csp_nonce: csp_nonce.to_owned(),
8240                    version: env!("CARGO_PKG_VERSION"),
8241                }
8242                .render()
8243                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8244                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8245            }
8246            let msg = format!(
8247                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8248                json_path.display()
8249            );
8250            let folder_hint = output_folder_hint(json_path);
8251            Err(missing_scan_relocate_response(
8252                &msg,
8253                run_id,
8254                &folder_hint,
8255                compare_url,
8256                false,
8257                csp_nonce,
8258            ))
8259        }
8260    }
8261}
8262
8263struct ChurnStats {
8264    new_scope: bool,
8265    scope_flag: bool,
8266    churn_rate_str: String,
8267    churn_rate_class: String,
8268}
8269
8270fn compute_churn_stats(
8271    baseline_code: u64,
8272    current_code: u64,
8273    lines_added: i64,
8274    lines_removed: i64,
8275) -> ChurnStats {
8276    let new_scope = baseline_code == 0 && current_code > 0;
8277    #[allow(clippy::cast_precision_loss)]
8278    let churn_pct = if baseline_code > 0 {
8279        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8280    } else {
8281        0.0
8282    };
8283    #[allow(clippy::cast_precision_loss)]
8284    let scope_flag =
8285        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8286    let churn_rate_str = if new_scope {
8287        "New".to_string()
8288    } else if baseline_code > 0 {
8289        format!("{churn_pct:.1}%")
8290    } else {
8291        "—".to_string()
8292    };
8293    let churn_rate_class = if new_scope || churn_pct > 20.0 {
8294        "high".to_string()
8295    } else if churn_pct > 5.0 {
8296        "med".to_string()
8297    } else {
8298        "low".to_string()
8299    };
8300    ChurnStats {
8301        new_scope,
8302        scope_flag,
8303        churn_rate_str,
8304        churn_rate_class,
8305    }
8306}
8307
8308/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
8309/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
8310/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
8311fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8312    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8313    if !has_data {
8314        return String::new();
8315    }
8316    let base_str = s
8317        .baseline_coverage_line_pct
8318        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8319    let curr_str = s
8320        .current_coverage_line_pct
8321        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8322    let (delta_str, cls) = match s.coverage_line_pct_delta {
8323        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8324        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8325        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8326        None => ("\u{2014}".into(), "zero"),
8327    };
8328    format!(
8329        r#"<div class="delta-card">
8330          <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>
8331          <div class="delta-card-label">Line coverage</div>
8332          <div class="delta-card-from">Before: {base_str}</div>
8333          <div class="delta-card-to">{curr_str}</div>
8334          <span class="delta-card-change {cls}">{delta_str}</span>
8335        </div>"#
8336    )
8337}
8338
8339/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
8340#[allow(clippy::ref_option)]
8341fn narrow_run_pair_by_scope(
8342    mut baseline: AnalysisRun,
8343    mut current: AnalysisRun,
8344    active_sub: &Option<String>,
8345    super_scope: bool,
8346) -> (AnalysisRun, AnalysisRun) {
8347    if let Some(ref sub_name) = active_sub {
8348        baseline
8349            .per_file_records
8350            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8351        current
8352            .per_file_records
8353            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8354        recompute_summary_from_records(&mut baseline);
8355        recompute_summary_from_records(&mut current);
8356    } else if super_scope {
8357        baseline.per_file_records.retain(|f| f.submodule.is_none());
8358        current.per_file_records.retain(|f| f.submodule.is_none());
8359        recompute_summary_from_records(&mut baseline);
8360        recompute_summary_from_records(&mut current);
8361    }
8362    (baseline, current)
8363}
8364
8365/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
8366#[allow(clippy::ref_option)]
8367fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8368    if let Some(ref sub_name) = active_sub {
8369        for run in runs.iter_mut() {
8370            run.per_file_records
8371                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8372            recompute_summary_from_records(run);
8373        }
8374    } else if super_scope {
8375        for run in runs.iter_mut() {
8376            run.per_file_records.retain(|f| f.submodule.is_none());
8377            recompute_summary_from_records(run);
8378        }
8379    }
8380}
8381
8382#[allow(clippy::too_many_lines)]
8383async fn compare_handler(
8384    State(state): State<AppState>,
8385    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8386    Query(query): Query<CompareQuery>,
8387) -> impl IntoResponse {
8388    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
8389    // redirect to the history page where the user can select two runs.
8390    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8391        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8392        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8393    };
8394
8395    let (maybe_a, maybe_b) = {
8396        let reg = state.registry.lock().await;
8397        (
8398            reg.find_by_run_id(&run_id_a).cloned(),
8399            reg.find_by_run_id(&run_id_b).cloned(),
8400        )
8401    };
8402
8403    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8404        let html = ErrorTemplate {
8405            message: "One or both run IDs were not found in scan history. \
8406                      The runs may have been deleted or the registry may have been reset."
8407                .to_string(),
8408            last_report_url: Some("/compare-scans".to_string()),
8409            last_report_label: Some("Compare Scans".to_string()),
8410            run_id: None,
8411            error_code: None,
8412            csp_nonce: csp_nonce.clone(),
8413            version: env!("CARGO_PKG_VERSION"),
8414        }
8415        .render()
8416        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8417        return Html(html).into_response();
8418    };
8419
8420    // Ensure older scan is always the baseline.
8421    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8422        (entry_a, entry_b)
8423    } else {
8424        (entry_b, entry_a)
8425    };
8426
8427    // If query params were in the wrong order, redirect to canonical URL so the
8428    // browser always shows the same URL for the same two scans regardless of how
8429    // the user arrived here (Full diff button vs. Compare Scans selection).
8430    if baseline_entry.run_id != run_id_a {
8431        let canonical = format!(
8432            "/compare?a={}&b={}",
8433            baseline_entry.run_id, current_entry.run_id
8434        );
8435        return axum::response::Redirect::to(&canonical).into_response();
8436    }
8437
8438    let (Some(base_json), Some(curr_json)) = (
8439        baseline_entry.json_path.as_ref(),
8440        current_entry.json_path.as_ref(),
8441    ) else {
8442        let html = ErrorTemplate {
8443            message: "Full comparison requires JSON scan data, which was not saved for one or \
8444                      both of these runs. JSON is now always saved for new scans — re-run the \
8445                      affected projects to enable comparisons."
8446                .to_string(),
8447            last_report_url: Some("/compare-scans".to_string()),
8448            last_report_label: Some("Compare Scans".to_string()),
8449            run_id: None,
8450            error_code: None,
8451            csp_nonce: csp_nonce.clone(),
8452            version: env!("CARGO_PKG_VERSION"),
8453        }
8454        .render()
8455        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8456        return Html(html).into_response();
8457    };
8458
8459    let compare_url = format!(
8460        "/compare?a={}&b={}",
8461        baseline_entry.run_id, current_entry.run_id
8462    );
8463
8464    let baseline_run = match load_scan_for_compare(
8465        base_json,
8466        "baseline",
8467        &baseline_entry.run_id,
8468        state.server_mode,
8469        &compare_url,
8470        &csp_nonce,
8471    ) {
8472        Ok(r) => r,
8473        Err(resp) => return resp,
8474    };
8475    let current_run = match load_scan_for_compare(
8476        curr_json,
8477        "current",
8478        &current_entry.run_id,
8479        state.server_mode,
8480        &compare_url,
8481        &csp_nonce,
8482    ) {
8483        Ok(r) => r,
8484        Err(resp) => return resp,
8485    };
8486
8487    let active_submodule = query.sub.clone();
8488    let super_scope_active = query.scope.as_deref() == Some("super");
8489
8490    let submodule_options = baseline_run
8491        .submodule_summaries
8492        .iter()
8493        .chain(current_run.submodule_summaries.iter())
8494        .map(|s| s.name.clone())
8495        .collect::<std::collections::BTreeSet<_>>()
8496        .into_iter()
8497        .collect::<Vec<_>>();
8498    let has_any_submodule_data = !submodule_options.is_empty();
8499
8500    // Narrow per_file_records when a scope is active, then recompute totals.
8501    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8502        baseline_run,
8503        current_run,
8504        &active_submodule,
8505        super_scope_active,
8506    );
8507
8508    let comparison = compute_delta(&effective_baseline, &effective_current);
8509
8510    let file_rows: Vec<CompareFileDeltaRow> = comparison
8511        .file_deltas
8512        .iter()
8513        .map(|d| CompareFileDeltaRow {
8514            relative_path: d.relative_path.clone(),
8515            language: d.language.clone().unwrap_or_else(|| "—".into()),
8516            status: match d.status {
8517                FileChangeStatus::Added => "added".into(),
8518                FileChangeStatus::Removed => "removed".into(),
8519                FileChangeStatus::Modified => "modified".into(),
8520                FileChangeStatus::Unchanged => "unchanged".into(),
8521            },
8522            baseline_code: d.baseline_code,
8523            current_code: d.current_code,
8524            baseline_code_display: if d.status == FileChangeStatus::Added {
8525                "—".into()
8526            } else {
8527                d.baseline_code.to_string()
8528            },
8529            current_code_display: if d.status == FileChangeStatus::Removed {
8530                "—".into()
8531            } else {
8532                d.current_code.to_string()
8533            },
8534            code_delta_str: fmt_delta(d.code_delta),
8535            code_delta_class: delta_class(d.code_delta).into(),
8536            comment_delta_str: fmt_delta(d.comment_delta),
8537            comment_delta_class: delta_class(d.comment_delta).into(),
8538            total_delta_str: fmt_delta(d.total_delta),
8539            total_delta_class: delta_class(d.total_delta).into(),
8540        })
8541        .collect();
8542
8543    let project_path = baseline_entry
8544        .input_roots
8545        .first()
8546        .map(|s| sanitize_path_str(s))
8547        .unwrap_or_default();
8548    let lines_added = sum_added_code_lines(&comparison);
8549    let lines_removed = sum_removed_code_lines(&comparison);
8550    let churn = compute_churn_stats(
8551        comparison.summary.baseline_code,
8552        comparison.summary.current_code,
8553        lines_added,
8554        lines_removed,
8555    );
8556    let s = &comparison.summary;
8557    let template = CompareTemplate {
8558        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8559        version: env!("CARGO_PKG_VERSION"),
8560        project_label: baseline_entry.project_label.clone(),
8561        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8562        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8563        baseline_run_id: baseline_entry.run_id.clone(),
8564        current_run_id: current_entry.run_id.clone(),
8565        baseline_run_id_short: baseline_entry
8566            .run_id
8567            .split('-')
8568            .next_back()
8569            .unwrap_or(&baseline_entry.run_id)
8570            .chars()
8571            .take(7)
8572            .collect(),
8573        current_run_id_short: current_entry
8574            .run_id
8575            .split('-')
8576            .next_back()
8577            .unwrap_or(&current_entry.run_id)
8578            .chars()
8579            .take(7)
8580            .collect(),
8581        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8582        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8583        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8584        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8585        project_path: project_path.clone(),
8586        baseline_code: s.baseline_code,
8587        current_code: s.current_code,
8588        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8589        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8590        baseline_files: s.baseline_files,
8591        current_files: s.current_files,
8592        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8593        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8594        baseline_comments: s.baseline_comments,
8595        current_comments: s.current_comments,
8596        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8597        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8598        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8599        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8600        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8601        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8602        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8603        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8604        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8605        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8606        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8607        code_lines_added: lines_added,
8608        code_lines_removed: lines_removed,
8609        code_lines_modified: sum_modified_code_lines(&comparison),
8610        code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8611        code_lines_total: lines_added
8612            + lines_removed
8613            + sum_modified_code_lines(&comparison)
8614            + sum_unmodified_code_lines(&comparison),
8615        new_scope: churn.new_scope,
8616        churn_rate_str: churn.churn_rate_str,
8617        churn_rate_class: churn.churn_rate_class,
8618        scope_flag: churn.scope_flag,
8619        files_added: comparison.files_added,
8620        files_removed: comparison.files_removed,
8621        files_modified: comparison.files_modified,
8622        files_unchanged: comparison.files_unchanged,
8623        files_total: comparison.files_total,
8624        file_rows,
8625        baseline_git_author: baseline_entry.git_author.clone(),
8626        current_git_author: current_entry.git_author.clone(),
8627        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8628        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8629        baseline_git_tags: baseline_entry.git_tags.clone(),
8630        current_git_tags: current_entry.git_tags.clone(),
8631        baseline_git_commit_date: baseline_entry
8632            .git_commit_date
8633            .as_deref()
8634            .and_then(fmt_git_date),
8635        current_git_commit_date: current_entry
8636            .git_commit_date
8637            .as_deref()
8638            .and_then(fmt_git_date),
8639        project_name: project_path
8640            .rsplit(['/', '\\'])
8641            .find(|s| !s.is_empty())
8642            .unwrap_or(&project_path)
8643            .to_string(),
8644        submodule_options,
8645        has_any_submodule_data,
8646        active_submodule,
8647        super_scope_active,
8648        toast_assets: sloc_toast_assets(&csp_nonce),
8649        csp_nonce,
8650        coverage_delta_card: build_coverage_delta_card(s),
8651        baseline_test_count: effective_baseline.summary_totals.test_count,
8652        current_test_count: effective_current.summary_totals.test_count,
8653        baseline_coverage_pct: s.baseline_coverage_line_pct,
8654        current_coverage_pct: s.current_coverage_line_pct,
8655    };
8656
8657    Html(
8658        template
8659            .render()
8660            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8661    )
8662    .into_response()
8663}
8664
8665// ── Badge endpoint ────────────────────────────────────────────────────────────
8666// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8667// pages, Jira descriptions, etc.
8668//
8669// GET /badge/<metric>?label=<override>&color=<hex>
8670// Metrics: code-lines  files  comment-lines  blank-lines
8671
8672fn format_number(n: u64) -> String {
8673    let s = n.to_string();
8674    let mut out = String::with_capacity(s.len() + s.len() / 3);
8675    let len = s.len();
8676    for (i, c) in s.chars().enumerate() {
8677        if i > 0 && (len - i).is_multiple_of(3) {
8678            out.push(',');
8679        }
8680        out.push(c);
8681    }
8682    out
8683}
8684
8685const fn badge_char_width(c: char) -> f64 {
8686    match c {
8687        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8688        'm' | 'w' => 9.0,
8689        ' ' => 4.0,
8690        _ => 6.5,
8691    }
8692}
8693
8694#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8695fn badge_text_px(text: &str) -> u32 {
8696    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8697}
8698
8699fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8700    let lw = badge_text_px(label) + 20;
8701    let rw = badge_text_px(value) + 20;
8702    let total = lw + rw;
8703    let lx = lw / 2;
8704    let rx = lw + rw / 2;
8705    let le = escape_html(label);
8706    let ve = escape_html(value);
8707    let ce = escape_html(color);
8708    format!(
8709        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8710  <rect width="{total}" height="20" fill="#555"/>
8711  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8712  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8713    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8714    <text x="{lx}" y="13">{le}</text>
8715    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8716    <text x="{rx}" y="13">{ve}</text>
8717  </g>
8718</svg>"##
8719    )
8720}
8721
8722#[derive(Deserialize)]
8723struct BadgeQuery {
8724    label: Option<String>,
8725    color: Option<String>,
8726}
8727
8728async fn badge_handler(
8729    State(state): State<AppState>,
8730    AxumPath(metric): AxumPath<String>,
8731    Query(query): Query<BadgeQuery>,
8732) -> Response {
8733    let entry = {
8734        let reg = state.registry.lock().await;
8735        reg.entries.first().cloned()
8736    };
8737
8738    let Some(entry) = entry else {
8739        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8740        return (
8741            [
8742                (header::CONTENT_TYPE, "image/svg+xml"),
8743                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8744            ],
8745            svg,
8746        )
8747            .into_response();
8748    };
8749
8750    let (default_label, value, default_color) = match metric.as_str() {
8751        "code-lines" => (
8752            "code lines",
8753            format_number(entry.summary.code_lines),
8754            "#4a78ee",
8755        ),
8756        "files" => (
8757            "files analyzed",
8758            format_number(entry.summary.files_analyzed),
8759            "#4a9862",
8760        ),
8761        "comment-lines" => (
8762            "comment lines",
8763            format_number(entry.summary.comment_lines),
8764            "#b35428",
8765        ),
8766        "blank-lines" => (
8767            "blank lines",
8768            format_number(entry.summary.blank_lines),
8769            "#7a5db0",
8770        ),
8771        _ => return StatusCode::NOT_FOUND.into_response(),
8772    };
8773
8774    let label = query.label.as_deref().unwrap_or(default_label);
8775    let color = query.color.as_deref().unwrap_or(default_color);
8776    let svg = render_badge_svg(label, &value, color);
8777
8778    (
8779        [
8780            (header::CONTENT_TYPE, "image/svg+xml"),
8781            (header::CACHE_CONTROL, "no-cache, max-age=0"),
8782        ],
8783        svg,
8784    )
8785        .into_response()
8786}
8787
8788// ── Metrics API ───────────────────────────────────────────────────────────────
8789// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
8790// Confluence automation, Jira webhooks, etc.
8791//
8792// GET /api/metrics/latest
8793// GET /api/metrics/<run_id>
8794
8795#[derive(Serialize)]
8796struct ApiCoverageBlock {
8797    lines_found: u64,
8798    lines_hit: u64,
8799    line_pct: f64,
8800    functions_found: u64,
8801    functions_hit: u64,
8802    function_pct: f64,
8803    branches_found: u64,
8804    branches_hit: u64,
8805    branch_pct: f64,
8806}
8807
8808#[derive(Serialize)]
8809struct ApiMetricsResponse {
8810    run_id: String,
8811    timestamp: String,
8812    project: String,
8813    summary: ApiSummaryPayload,
8814    languages: Vec<ApiLanguageRow>,
8815    #[serde(skip_serializing_if = "Option::is_none")]
8816    coverage: Option<ApiCoverageBlock>,
8817}
8818
8819#[derive(Serialize)]
8820struct ApiSummaryPayload {
8821    files_analyzed: u64,
8822    files_skipped: u64,
8823    code_lines: u64,
8824    comment_lines: u64,
8825    blank_lines: u64,
8826    total_physical_lines: u64,
8827    functions: u64,
8828    classes: u64,
8829    variables: u64,
8830    imports: u64,
8831}
8832
8833#[derive(Serialize)]
8834struct ApiLanguageRow {
8835    name: String,
8836    files: u64,
8837    code_lines: u64,
8838    comment_lines: u64,
8839    blank_lines: u64,
8840    functions: u64,
8841    classes: u64,
8842    variables: u64,
8843    imports: u64,
8844}
8845
8846async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8847    let entry = {
8848        let reg = state.registry.lock().await;
8849        reg.entries.first().cloned()
8850    };
8851    entry.map_or_else(
8852        || error::not_found("no scans recorded yet"),
8853        |e| build_metrics_response(&e),
8854    )
8855}
8856
8857async fn api_metrics_run_handler(
8858    State(state): State<AppState>,
8859    AxumPath(run_id): AxumPath<String>,
8860) -> Response {
8861    let entry = {
8862        let reg = state.registry.lock().await;
8863        reg.find_by_run_id(&run_id).cloned()
8864    };
8865    entry.map_or_else(
8866        || error::not_found("run not found"),
8867        |e| build_metrics_response(&e),
8868    )
8869}
8870
8871fn build_metrics_response(entry: &RegistryEntry) -> Response {
8872    let languages: Vec<ApiLanguageRow> = entry
8873        .json_path
8874        .as_ref()
8875        .and_then(|p| read_json(p).ok())
8876        .map(|run| {
8877            run.totals_by_language
8878                .iter()
8879                .map(|l| ApiLanguageRow {
8880                    name: l.language.display_name().to_string(),
8881                    files: l.files,
8882                    code_lines: l.code_lines,
8883                    comment_lines: l.comment_lines,
8884                    blank_lines: l.blank_lines,
8885                    functions: l.functions,
8886                    classes: l.classes,
8887                    variables: l.variables,
8888                    imports: l.imports,
8889                })
8890                .collect()
8891        })
8892        .unwrap_or_default();
8893
8894    let s = &entry.summary;
8895    let coverage = if s.coverage_lines_found > 0 {
8896        let pct = |hit: u64, found: u64| -> f64 {
8897            if found == 0 {
8898                0.0
8899            } else {
8900                #[allow(clippy::cast_precision_loss)]
8901                let v = (hit as f64 / found as f64) * 100.0;
8902                (v * 10.0).round() / 10.0
8903            }
8904        };
8905        Some(ApiCoverageBlock {
8906            lines_found: s.coverage_lines_found,
8907            lines_hit: s.coverage_lines_hit,
8908            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8909            functions_found: s.coverage_functions_found,
8910            functions_hit: s.coverage_functions_hit,
8911            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8912            branches_found: s.coverage_branches_found,
8913            branches_hit: s.coverage_branches_hit,
8914            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8915        })
8916    } else {
8917        None
8918    };
8919    Json(ApiMetricsResponse {
8920        run_id: entry.run_id.clone(),
8921        timestamp: entry.timestamp_utc.to_rfc3339(),
8922        project: entry.project_label.clone(),
8923        summary: ApiSummaryPayload {
8924            files_analyzed: s.files_analyzed,
8925            files_skipped: s.files_skipped,
8926            code_lines: s.code_lines,
8927            comment_lines: s.comment_lines,
8928            blank_lines: s.blank_lines,
8929            total_physical_lines: s.total_physical_lines,
8930            functions: s.functions,
8931            classes: s.classes,
8932            variables: s.variables,
8933            imports: s.imports,
8934        },
8935        languages,
8936        coverage,
8937    })
8938    .into_response()
8939}
8940
8941// ── Project history API ───────────────────────────────────────────────────────
8942// Protected. Called by the wizard JS when the project path changes, so the UI
8943// can show a "scanned N times before" badge without a full page reload.
8944//
8945// GET /api/project-history?path=<project_root>
8946
8947#[derive(Deserialize)]
8948struct ProjectHistoryQuery {
8949    path: Option<String>,
8950}
8951
8952#[derive(Serialize)]
8953struct ProjectHistoryResponse {
8954    scan_count: usize,
8955    last_scan_id: Option<String>,
8956    last_scan_timestamp: Option<String>,
8957    last_scan_code_lines: Option<u64>,
8958    last_git_branch: Option<String>,
8959    last_git_commit: Option<String>,
8960}
8961
8962/// Return true if `entry` matches either an exact root path or an upload-staging
8963/// path with the same project name (needed because each upload gets a fresh UUID dir).
8964fn entry_matches_project(
8965    entry: &RegistryEntry,
8966    root_str: &str,
8967    upload_root: &str,
8968    upload_name_suffix: Option<&str>,
8969) -> bool {
8970    if entry.input_roots.iter().any(|r| r == root_str) {
8971        return true;
8972    }
8973    if let Some(suffix) = upload_name_suffix {
8974        return entry
8975            .input_roots
8976            .iter()
8977            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8978    }
8979    false
8980}
8981
8982async fn project_history_handler(
8983    State(state): State<AppState>,
8984    Query(query): Query<ProjectHistoryQuery>,
8985) -> Response {
8986    let path = query.path.unwrap_or_default();
8987    let resolved = resolve_input_path(&path);
8988    let root_str = resolved.to_string_lossy().replace('\\', "/");
8989
8990    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
8991    // The UUID is freshly generated for every upload, so an exact root_str match never finds
8992    // previous scans of the same project. Fall back to matching by project name within the
8993    // uploads staging directory so Scan History populates correctly across uploads.
8994    let upload_root = std::env::temp_dir()
8995        .join("oxide-sloc-uploads")
8996        .to_string_lossy()
8997        .replace('\\', "/");
8998    let upload_name_suffix: Option<String> =
8999        if state.server_mode && root_str.starts_with(&upload_root) {
9000            resolved
9001                .file_name()
9002                .and_then(|n| n.to_str())
9003                .map(|name| format!("/{name}"))
9004        } else {
9005            None
9006        };
9007    let suffix_ref = upload_name_suffix.as_deref();
9008
9009    let entries: Vec<_> = {
9010        let reg = state.registry.lock().await;
9011        reg.entries
9012            .iter()
9013            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9014            .cloned()
9015            .collect()
9016    };
9017    let scan_count = entries.len();
9018    let last = entries.first();
9019    let last_scan_id = last.map(|e| e.run_id.clone());
9020    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9021    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9022    let last_git_branch = last.and_then(|e| e.git_branch.clone());
9023    let last_git_commit = last.and_then(|e| e.git_commit.clone());
9024
9025    Json(ProjectHistoryResponse {
9026        scan_count,
9027        last_scan_id,
9028        last_scan_timestamp,
9029        last_scan_code_lines,
9030        last_git_branch,
9031        last_git_commit,
9032    })
9033    .into_response()
9034}
9035
9036// ── Metrics history API ───────────────────────────────────────────────────────
9037// Protected. Returns a JSON array of lightweight scan snapshots for plotting
9038// trend charts.
9039//
9040// GET /api/metrics/history?root=<path>&limit=<n>
9041
9042#[derive(Deserialize)]
9043struct MetricsHistoryQuery {
9044    root: Option<String>,
9045    limit: Option<usize>,
9046    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
9047    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
9048    submodule: Option<String>,
9049}
9050
9051#[derive(Serialize)]
9052struct MetricsSubmoduleLink {
9053    name: String,
9054    url: String,
9055}
9056
9057#[derive(Serialize)]
9058struct MetricsHistoryEntry {
9059    run_id: String,
9060    run_id_short: String,
9061    timestamp: String,
9062    commit: Option<String>,
9063    branch: Option<String>,
9064    tags: Vec<String>,
9065    nearest_tag: Option<String>,
9066    code_lines: u64,
9067    comment_lines: u64,
9068    blank_lines: u64,
9069    physical_lines: u64,
9070    files_analyzed: u64,
9071    files_skipped: u64,
9072    test_count: u64,
9073    project_label: String,
9074    html_url: Option<String>,
9075    has_pdf: bool,
9076    submodule_links: Vec<MetricsSubmoduleLink>,
9077    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
9078    #[serde(skip_serializing_if = "Option::is_none")]
9079    coverage_line_pct: Option<f64>,
9080}
9081
9082fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9083    let mut links: Vec<MetricsSubmoduleLink> = vec![];
9084    let sub_dir = e
9085        .html_path
9086        .as_ref()
9087        .and_then(|p| p.parent())
9088        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9089    let Some(dir) = sub_dir else { return links };
9090    let Ok(rd) = std::fs::read_dir(dir) else {
9091        return links;
9092    };
9093    for entry_res in rd.flatten() {
9094        let fname = entry_res.file_name();
9095        let fname_str = fname.to_string_lossy();
9096        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9097            let stem = &fname_str[..fname_str.len() - 5];
9098            let display = stem[4..].replace('-', " ");
9099            links.push(MetricsSubmoduleLink {
9100                name: display,
9101                url: format!("/runs/{stem}/{}", e.run_id),
9102            });
9103        }
9104    }
9105    links.sort_by(|a, b| a.name.cmp(&b.name));
9106    links
9107}
9108
9109fn apply_submodule_filter(
9110    base: MetricsHistoryEntry,
9111    filter: &str,
9112    e: &sloc_core::history::RegistryEntry,
9113) -> Option<MetricsHistoryEntry> {
9114    let json_path = e.json_path.as_ref()?;
9115    let json_str = std::fs::read_to_string(json_path).ok()?;
9116    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9117    let sub = run
9118        .submodule_summaries
9119        .iter()
9120        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9121    let safe = sanitize_project_label(&sub.name);
9122    let artifact_key = format!("sub_{safe}");
9123    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9124        || base.html_url.clone(),
9125        |run_dir| {
9126            let sub_path = run_dir.join(format!("{artifact_key}.html"));
9127            if sub_path.exists() {
9128                Some(format!("/runs/{artifact_key}/{}", e.run_id))
9129            } else {
9130                base.html_url.clone()
9131            }
9132        },
9133    );
9134
9135    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
9136    // basic SLOC totals, so test_count and coverage must be computed from file records.
9137    let sub_files: Vec<_> = run
9138        .per_file_records
9139        .iter()
9140        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9141        .collect();
9142    let test_count: u64 = sub_files
9143        .iter()
9144        .map(|r| r.raw_line_categories.test_count)
9145        .sum();
9146    #[allow(clippy::cast_precision_loss)]
9147    let coverage_line_pct: Option<f64> = {
9148        let found: u64 = sub_files
9149            .iter()
9150            .filter_map(|r| r.coverage.as_ref())
9151            .map(|c| u64::from(c.lines_found))
9152            .sum();
9153        let hit: u64 = sub_files
9154            .iter()
9155            .filter_map(|r| r.coverage.as_ref())
9156            .map(|c| u64::from(c.lines_hit))
9157            .sum();
9158        if found > 0 {
9159            let pct = (hit as f64 / found as f64) * 100.0;
9160            Some((pct * 10.0).round() / 10.0)
9161        } else {
9162            None
9163        }
9164    };
9165
9166    Some(MetricsHistoryEntry {
9167        code_lines: sub.code_lines,
9168        comment_lines: sub.comment_lines,
9169        blank_lines: sub.blank_lines,
9170        physical_lines: sub.total_physical_lines,
9171        files_analyzed: sub.files_analyzed,
9172        files_skipped: 0,
9173        test_count,
9174        html_url: sub_html_url,
9175        has_pdf: false,
9176        submodule_links: vec![],
9177        coverage_line_pct,
9178        ..base
9179    })
9180}
9181
9182#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
9183async fn api_metrics_history_handler(
9184    State(state): State<AppState>,
9185    Query(query): Query<MetricsHistoryQuery>,
9186) -> Response {
9187    let limit = query.limit.unwrap_or(50).min(500);
9188    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9189
9190    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9191        let reg = state.registry.lock().await;
9192        reg.entries
9193            .iter()
9194            .filter(|e| {
9195                query.root.as_ref().is_none_or(|root| {
9196                    let resolved = resolve_input_path(root);
9197                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9198                    e.input_roots.iter().any(|r| r == &root_str)
9199                })
9200            })
9201            .take(limit)
9202            .cloned()
9203            .collect()
9204    };
9205
9206    let entries: Vec<MetricsHistoryEntry> = candidate_entries
9207        .into_iter()
9208        .filter_map(|e| {
9209            let tags = e
9210                .git_tags
9211                .as_deref()
9212                .map(|s| {
9213                    s.split(',')
9214                        .map(|t| t.trim().to_string())
9215                        .filter(|t| !t.is_empty())
9216                        .collect()
9217                })
9218                .unwrap_or_default();
9219            let html_url = e
9220                .html_path
9221                .as_ref()
9222                .filter(|p| p.exists())
9223                .map(|_| format!("/runs/html/{}", e.run_id));
9224            let nearest_tag = e.git_nearest_tag.clone();
9225            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9226            let run_id_short: String = e
9227                .run_id
9228                .split('-')
9229                .next_back()
9230                .unwrap_or(&e.run_id)
9231                .chars()
9232                .take(7)
9233                .collect();
9234            let submodule_links = build_entry_submodule_links(&e);
9235            #[allow(clippy::cast_precision_loss)]
9236            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9237                let pct = (e.summary.coverage_lines_hit as f64
9238                    / e.summary.coverage_lines_found as f64)
9239                    * 100.0;
9240                Some((pct * 10.0).round() / 10.0)
9241            } else {
9242                None
9243            };
9244            let base = MetricsHistoryEntry {
9245                run_id: e.run_id.clone(),
9246                run_id_short,
9247                timestamp: e.timestamp_utc.to_rfc3339(),
9248                commit: e.git_commit.clone(),
9249                branch: e.git_branch.clone(),
9250                tags,
9251                nearest_tag,
9252                code_lines: e.summary.code_lines,
9253                comment_lines: e.summary.comment_lines,
9254                blank_lines: e.summary.blank_lines,
9255                physical_lines: e.summary.total_physical_lines,
9256                files_analyzed: e.summary.files_analyzed,
9257                files_skipped: e.summary.files_skipped,
9258                test_count: e.summary.test_count,
9259                project_label: e.project_label.clone(),
9260                html_url,
9261                has_pdf,
9262                submodule_links,
9263                coverage_line_pct,
9264            };
9265            if let Some(ref filter) = submodule_filter {
9266                apply_submodule_filter(base, filter, &e)
9267            } else {
9268                Some(base)
9269            }
9270        })
9271        .collect();
9272
9273    Json(entries).into_response()
9274}
9275
9276/// One scan's code churn versus the previous scan of the same project.
9277#[derive(Serialize)]
9278struct ChurnEntry {
9279    run_id: String,
9280    added: i64,
9281    removed: i64,
9282    modified: i64,
9283    unmodified: i64,
9284}
9285
9286// GET /api/metrics/churn?root=<path>&limit=<n>
9287// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
9288// comparing each scan to the previous scan of the same project. Loads per-file JSON
9289// artifacts, so it is intended for export-time use rather than every page load.
9290async fn api_metrics_churn_handler(
9291    State(state): State<AppState>,
9292    Query(query): Query<MetricsHistoryQuery>,
9293) -> Response {
9294    let limit = query.limit.unwrap_or(200).min(500);
9295    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9296        let reg = state.registry.lock().await;
9297        reg.entries
9298            .iter()
9299            .filter(|e| {
9300                query.root.as_ref().is_none_or(|root| {
9301                    let resolved = resolve_input_path(root);
9302                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9303                    e.input_roots.iter().any(|r| r == &root_str)
9304                })
9305            })
9306            .take(limit)
9307            .cloned()
9308            .collect()
9309    };
9310    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9311        std::collections::HashMap::new();
9312    for e in candidate_entries {
9313        by_project
9314            .entry(e.project_label.clone())
9315            .or_default()
9316            .push(e);
9317    }
9318    let mut out: Vec<ChurnEntry> = Vec::new();
9319    for (_proj, mut entries) in by_project {
9320        entries.sort_by_key(|e| e.timestamp_utc);
9321        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9322        for e in &entries {
9323            let curr = e
9324                .json_path
9325                .as_ref()
9326                .and_then(|path| sloc_core::read_json(path).ok());
9327            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9328                let cmp = sloc_core::compute_delta(prev, cur);
9329                out.push(ChurnEntry {
9330                    run_id: e.run_id.clone(),
9331                    added: sum_added_code_lines(&cmp),
9332                    removed: sum_removed_code_lines(&cmp),
9333                    modified: sum_modified_code_lines(&cmp),
9334                    unmodified: sum_unmodified_code_lines(&cmp),
9335                });
9336            } else {
9337                out.push(ChurnEntry {
9338                    run_id: e.run_id.clone(),
9339                    added: 0,
9340                    removed: 0,
9341                    modified: 0,
9342                    unmodified: 0,
9343                });
9344            }
9345            if curr.is_some() {
9346                prev_run = curr;
9347            }
9348        }
9349    }
9350    Json(out).into_response()
9351}
9352
9353// GET /api/metrics/submodules?root=<path>
9354// Returns the union of distinct submodule names found across all saved scan JSON artifacts
9355// for the given project root (or all roots if omitted).
9356#[derive(Deserialize)]
9357struct MetricsSubmodulesQuery {
9358    root: Option<String>,
9359}
9360
9361#[derive(Serialize)]
9362struct SubmoduleEntry {
9363    name: String,
9364    relative_path: String,
9365}
9366
9367async fn api_metrics_submodules_handler(
9368    State(state): State<AppState>,
9369    Query(query): Query<MetricsSubmodulesQuery>,
9370) -> Response {
9371    let json_paths: Vec<std::path::PathBuf> = {
9372        let reg = state.registry.lock().await;
9373        reg.entries
9374            .iter()
9375            .filter(|e| {
9376                query.root.as_ref().is_none_or(|root| {
9377                    let resolved = resolve_input_path(root);
9378                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9379                    e.input_roots.iter().any(|r| r == &root_str)
9380                })
9381            })
9382            .filter_map(|e| e.json_path.clone())
9383            .collect()
9384    };
9385
9386    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9387    let mut result: Vec<SubmoduleEntry> = Vec::new();
9388
9389    for path in &json_paths {
9390        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9391            continue;
9392        };
9393        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9394            continue;
9395        };
9396        for sub in &run.submodule_summaries {
9397            if seen.insert(sub.name.clone()) {
9398                result.push(SubmoduleEntry {
9399                    name: sub.name.clone(),
9400                    relative_path: sub.relative_path.clone(),
9401                });
9402            }
9403        }
9404    }
9405
9406    result.sort_by(|a, b| a.name.cmp(&b.name));
9407    Json(result).into_response()
9408}
9409
9410// ── CI ingest endpoint ────────────────────────────────────────────────────────
9411// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
9412// server stores and displays results without cloning or scanning anything itself.
9413//
9414// POST /api/ingest?label=<optional_display_name>
9415// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
9416// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
9417
9418#[derive(Deserialize)]
9419struct IngestQuery {
9420    label: Option<String>,
9421}
9422
9423#[derive(Serialize)]
9424struct IngestResponse {
9425    run_id: String,
9426    view_url: String,
9427}
9428
9429async fn api_ingest_handler(
9430    State(state): State<AppState>,
9431    Query(q): Query<IngestQuery>,
9432    Json(run): Json<sloc_core::AnalysisRun>,
9433) -> Response {
9434    let label = q.label.unwrap_or_else(|| {
9435        run.input_roots
9436            .first()
9437            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9438    });
9439
9440    let label_for_task = label.clone();
9441    let result = tokio::task::spawn_blocking(move || {
9442        let html = render_html(&run)?;
9443        let run_id = run.tool.run_id.clone();
9444        let run_id_safe = run_id.len() <= 128
9445            && !run_id.is_empty()
9446            && run_id
9447                .chars()
9448                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9449        if !run_id_safe {
9450            anyhow::bail!(
9451                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9452            );
9453        }
9454        let project_label = sanitize_project_label(&label_for_task);
9455        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9456        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9457            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9458            _ => project_label,
9459        };
9460        let (artifacts, _pending_pdf) = persist_run_artifacts(
9461            &run,
9462            &html,
9463            &output_dir,
9464            &label_for_task,
9465            &file_stem,
9466            RunResultContext::default(),
9467        )?;
9468        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9469    })
9470    .await;
9471
9472    match result {
9473        Ok(Ok((run_id, artifacts, run))) => {
9474            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9475            (
9476                StatusCode::CREATED,
9477                Json(IngestResponse {
9478                    view_url: format!("/view-reports?run_id={run_id}"),
9479                    run_id,
9480                }),
9481            )
9482                .into_response()
9483        }
9484        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9485        Err(e) => error::internal(&format!("{e}")),
9486    }
9487}
9488
9489// ── Multi-compare page ────────────────────────────────────────────────────────
9490// GET /multi-compare?runs=id1,id2,id3,...
9491
9492fn html_escape(s: &str) -> String {
9493    s.replace('&', "&amp;")
9494        .replace('<', "&lt;")
9495        .replace('>', "&gt;")
9496        .replace('"', "&quot;")
9497}
9498
9499#[allow(clippy::cast_precision_loss)]
9500fn fmt_num(n: i64) -> String {
9501    let a = n.unsigned_abs();
9502    if a >= 1_000_000 {
9503        let v = n as f64 / 1_000_000.0;
9504        let s = format!("{v:.1}");
9505        format!("{}M", s.trim_end_matches(".0"))
9506    } else if a >= 10_000 {
9507        let v = n as f64 / 1_000.0;
9508        let s = format!("{v:.1}");
9509        format!("{}K", s.trim_end_matches(".0"))
9510    } else {
9511        let sign = if n < 0 { "-" } else { "" };
9512        if a < 1_000 {
9513            return format!("{sign}{a}");
9514        }
9515        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9516    }
9517}
9518
9519fn fmt_comma(n: i64) -> String {
9520    let sign = if n < 0 { "-" } else { "" };
9521    let a = n.unsigned_abs();
9522    if a < 1_000 {
9523        return format!("{sign}{a}");
9524    }
9525    let s = a.to_string();
9526    let bytes = s.as_bytes();
9527    let len = bytes.len();
9528    let mut out = String::with_capacity(len + len / 3);
9529    for (i, &b) in bytes.iter().enumerate() {
9530        if i > 0 && (len - i).is_multiple_of(3) {
9531            out.push(',');
9532        }
9533        out.push(b as char);
9534    }
9535    format!("{sign}{out}")
9536}
9537
9538/// Insert thousands separators into the integer portion of a number's textual form.
9539///
9540/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9541/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9542/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9543/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9544fn group_thousands(s: &str) -> String {
9545    let (sign, rest) = match s.as_bytes().first() {
9546        Some(b'-') => ("-", &s[1..]),
9547        Some(b'+') => ("+", &s[1..]),
9548        _ => ("", s),
9549    };
9550    let (int_part, frac_part) = match rest.split_once('.') {
9551        Some((i, f)) => (i, Some(f)),
9552        None => (rest, None),
9553    };
9554    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9555        return s.to_string();
9556    }
9557    let bytes = int_part.as_bytes();
9558    let len = bytes.len();
9559    let mut grouped = String::with_capacity(len + len / 3);
9560    for (i, &b) in bytes.iter().enumerate() {
9561        if i > 0 && (len - i).is_multiple_of(3) {
9562            grouped.push(',');
9563        }
9564        grouped.push(b as char);
9565    }
9566    frac_part.map_or_else(
9567        || format!("{sign}{grouped}"),
9568        |f| format!("{sign}{grouped}.{f}"),
9569    )
9570}
9571
9572/// Custom Askama filters available to templates in this crate.
9573mod filters {
9574    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9575    // (a `&self` `execute` method returning `Result`), not on our own source.
9576    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9577    use askama::{Result, Values};
9578
9579    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9580    ///
9581    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9582    /// (dashes, "No prior scan", etc.) passes through untouched.
9583    #[askama::filter_fn]
9584    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9585        Ok(super::group_thousands(&value.to_string()))
9586    }
9587}
9588
9589#[derive(Deserialize, Default)]
9590struct MultiCompareQuery {
9591    runs: Option<String>,
9592    /// "super" to show only super-repo files (exclude all submodule files)
9593    scope: Option<String>,
9594    /// Submodule name to narrow the comparison to one submodule
9595    sub: Option<String>,
9596}
9597
9598#[allow(clippy::too_many_lines)]
9599async fn multi_compare_handler(
9600    State(state): State<AppState>,
9601    Query(params): Query<MultiCompareQuery>,
9602    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9603) -> impl IntoResponse {
9604    let run_ids: Vec<String> = params
9605        .runs
9606        .as_deref()
9607        .unwrap_or("")
9608        .split(',')
9609        .map(|s| s.trim().to_string())
9610        .filter(|s| !s.is_empty())
9611        .collect();
9612
9613    if run_ids.len() < 2 {
9614        return Html(
9615            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9616             <a href=\"/compare-scans\">Go back</a></p>",
9617        )
9618        .into_response();
9619    }
9620    if run_ids.len() > 20 {
9621        return Html(
9622            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9623             at once. <a href=\"/compare-scans\">Go back</a></p>",
9624        )
9625        .into_response();
9626    }
9627
9628    // Look up each run_id in the registry.
9629    let entries: Vec<Option<RegistryEntry>> = {
9630        let reg = state.registry.lock().await;
9631        run_ids
9632            .iter()
9633            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9634            .collect()
9635    };
9636
9637    for (i, entry) in entries.iter().enumerate() {
9638        if entry.is_none() {
9639            let html = format!(
9640                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9641                 found. <a href=\"/compare-scans\">Go back</a></p>",
9642                run_ids[i]
9643            );
9644            return Html(html).into_response();
9645        }
9646    }
9647
9648    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9649
9650    for entry in &entries {
9651        if entry.json_path.is_none() {
9652            let html = format!(
9653                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9654                 JSON data — re-run the analysis to enable comparison. \
9655                 <a href=\"/compare-scans\">Go back</a></p>",
9656                entry.run_id
9657            );
9658            return Html(html).into_response();
9659        }
9660    }
9661
9662    // Sort chronologically.
9663    entries.sort_by_key(|e| e.timestamp_utc);
9664
9665    // Load JSON for each entry.
9666    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9667    for entry in &entries {
9668        let path = entry.json_path.as_ref().unwrap();
9669        match read_json(path) {
9670            Ok(r) => runs.push(r),
9671            Err(e) => {
9672                let html = format!(
9673                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9674                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9675                    entry.run_id
9676                );
9677                return Html(html).into_response();
9678            }
9679        }
9680    }
9681
9682    // Collect submodule names from all runs.
9683    let all_sub_names: Vec<String> = {
9684        let mut set = std::collections::BTreeSet::new();
9685        for r in &runs {
9686            for s in &r.submodule_summaries {
9687                set.insert(s.name.clone());
9688            }
9689        }
9690        set.into_iter().collect()
9691    };
9692    let has_submodule_data = !all_sub_names.is_empty();
9693    let active_submodule = params.sub.clone();
9694    let super_scope_active = params.scope.as_deref() == Some("super");
9695
9696    // Narrow per_file_records when a scope is active, then recompute totals.
9697    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9698
9699    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9700    let project_label = entries
9701        .first()
9702        .map_or("", |e| e.project_label.as_str())
9703        .to_string();
9704    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9705    let multi = compute_multi_delta(&run_refs);
9706    let html = multi_compare_page(
9707        &multi,
9708        &project_label,
9709        env!("CARGO_PKG_VERSION"),
9710        &csp_nonce,
9711        has_submodule_data,
9712        &all_sub_names,
9713        &runs_csv,
9714        super_scope_active,
9715        active_submodule.as_deref(),
9716        &entries,
9717    );
9718    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9719    // copy after a rebuild would silently mask UI fixes.
9720    (
9721        [(axum::http::header::CACHE_CONTROL, "no-store")],
9722        Html(html),
9723    )
9724        .into_response()
9725}
9726
9727const fn multi_delta_class(n: i64) -> &'static str {
9728    match n {
9729        1.. => "pos",
9730        ..=-1 => "neg",
9731        0 => "zero",
9732    }
9733}
9734
9735fn multi_fmt_delta(n: i64) -> String {
9736    if n > 0 {
9737        format!("+{n}")
9738    } else {
9739        format!("{n}")
9740    }
9741}
9742
9743/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9744fn js_escape(s: &str) -> String {
9745    use std::fmt::Write as _;
9746    let mut out = String::with_capacity(s.len() + 2);
9747    for c in s.chars() {
9748        match c {
9749            '"' => out.push_str("\\\""),
9750            '\\' => out.push_str("\\\\"),
9751            '\n' => out.push_str("\\n"),
9752            '\r' => out.push_str("\\r"),
9753            '\t' => out.push_str("\\t"),
9754            c if (c as u32) < 0x20 => {
9755                let _ = write!(out, "\\u{:04x}", c as u32);
9756            }
9757            c => out.push(c),
9758        }
9759    }
9760    out
9761}
9762
9763/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9764fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9765    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9766        return (
9767            "&mdash;".to_string(),
9768            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9769        );
9770    };
9771    let cd = entry
9772        .git_commit_date
9773        .as_deref()
9774        .and_then(fmt_git_date)
9775        .unwrap_or_else(|| "&mdash;".to_string());
9776    let au = entry.git_author.as_deref().map_or_else(
9777        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9778        |a| {
9779            format!(
9780                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9781                 <span class=\"cmp-author-handle\"></span></span>",
9782                html_escape(a)
9783            )
9784        },
9785    );
9786    (cd, au)
9787}
9788
9789/// Render the scope badge chip for a scan card header.
9790fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9791    active_sub.map_or_else(
9792        || {
9793            if super_scope_active {
9794                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9795            } else {
9796                "<span class=\"mc-scope-tag mc-scope-full\">\
9797                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9798                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9799                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9800                 <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>\
9801                 </svg> Full scan</span>"
9802                    .to_string()
9803            }
9804        },
9805        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9806    )
9807}
9808
9809/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
9810fn build_mc_scan_strip(
9811    multi: &MultiScanComparison,
9812    entries: &[RegistryEntry],
9813    n: usize,
9814    is_many: bool,
9815    active_sub: Option<&str>,
9816    super_scope_active: bool,
9817    project_label: &str,
9818) -> String {
9819    use std::fmt::Write as _;
9820    let mut scan_strip = String::new();
9821    for (i, pt) in multi.points.iter().enumerate() {
9822        let ts_ms = pt.timestamp.timestamp_millis();
9823        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9824        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9825        let branch = pt.git_branch.as_deref().unwrap_or("");
9826        let report_link = format!("/runs/html/{}", pt.run_id);
9827        let branch_html = if branch.is_empty() {
9828            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
9829        } else {
9830            format!(
9831                "<span class=\"mc-card-branch\">{}</span>",
9832                html_escape(branch)
9833            )
9834        };
9835        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9836        let tags_html = pt
9837            .git_tags
9838            .as_deref()
9839            .filter(|t| !t.is_empty())
9840            .map(|t| {
9841                let chips = t
9842                    .split(',')
9843                    .filter(|s| !s.is_empty())
9844                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9845                    .collect::<Vec<_>>()
9846                    .join(" ");
9847                format!(
9848                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9849                     <span class=\"mc-row-val\">{chips}</span></div>"
9850                )
9851            })
9852            .unwrap_or_default();
9853        let nearest = pt
9854            .git_nearest_tag
9855            .as_deref()
9856            .map(|t| format!("near {}", html_escape(t)))
9857            .unwrap_or_default();
9858        let arrow = if i < n - 1 && !is_many {
9859            "<div class='mc-arrow'>&#8594;</div>"
9860        } else {
9861            ""
9862        };
9863        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9864        let nearest_html = if nearest.is_empty() {
9865            String::new()
9866        } else {
9867            format!(
9868                "<span class=\"mc-card-nearest-wrap\">\
9869                 <span class=\"mc-card-nearest\">{nearest}</span>\
9870                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9871                 </span>"
9872            )
9873        };
9874        write!(
9875            scan_strip,
9876            r#"<div class="mc-card">
9877              <div class="mc-card-header">
9878                <div class="mc-card-num">Scan {num}</div>
9879                <div class="mc-card-project-col">
9880                  <div class="mc-card-project">{project_label}</div>
9881                  {scope_badge}
9882                </div>
9883              </div>
9884              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9885              <div class="mc-card-rows">
9886                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9887                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9888                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9889                <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>
9890                {tags_html}
9891              </div>
9892              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9893            </div>{arrow}"#,
9894            num = i + 1,
9895            commit = html_escape(commit),
9896            commit_date = commit_date_html,
9897            ts_ms = ts_ms,
9898            code = fmt_num(pt.code_lines),
9899            scope_badge = scope_badge,
9900            nearest_html = nearest_html,
9901        )
9902        .unwrap();
9903    }
9904    scan_strip
9905}
9906
9907/// Build the metric progression table (thead + tbody) for multi-compare.
9908#[allow(clippy::too_many_lines)]
9909fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9910    use std::fmt::Write as _;
9911    struct MetricRow<'a> {
9912        label: &'a str,
9913        values: Vec<i64>,
9914        seq_deltas: Vec<i64>,
9915        net_delta: i64,
9916    }
9917    let rows: Vec<MetricRow<'_>> = vec![
9918        MetricRow {
9919            label: "Code Lines",
9920            values: multi.points.iter().map(|p| p.code_lines).collect(),
9921            seq_deltas: multi
9922                .sequential_deltas
9923                .iter()
9924                .map(|d| d.summary.code_lines_delta)
9925                .collect(),
9926            net_delta: multi.total_delta.code_lines_delta,
9927        },
9928        MetricRow {
9929            label: "Files Analyzed",
9930            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9931            seq_deltas: multi
9932                .sequential_deltas
9933                .iter()
9934                .map(|d| d.summary.files_analyzed_delta)
9935                .collect(),
9936            net_delta: multi.total_delta.files_analyzed_delta,
9937        },
9938        MetricRow {
9939            label: "Comment Lines",
9940            values: multi.points.iter().map(|p| p.comment_lines).collect(),
9941            seq_deltas: multi
9942                .sequential_deltas
9943                .iter()
9944                .map(|d| d.summary.comment_lines_delta)
9945                .collect(),
9946            net_delta: multi.total_delta.comment_lines_delta,
9947        },
9948        MetricRow {
9949            label: "Blank Lines",
9950            values: multi.points.iter().map(|p| p.blank_lines).collect(),
9951            seq_deltas: multi
9952                .sequential_deltas
9953                .iter()
9954                .map(|d| d.summary.blank_lines_delta)
9955                .collect(),
9956            net_delta: multi.total_delta.blank_lines_delta,
9957        },
9958        MetricRow {
9959            label: "Tests",
9960            values: multi.points.iter().map(|p| p.test_count).collect(),
9961            seq_deltas: multi
9962                .points
9963                .windows(2)
9964                .map(|pts| pts[1].test_count - pts[0].test_count)
9965                .collect(),
9966            net_delta: multi.points.last().map_or(0, |l| l.test_count)
9967                - multi.points.first().map_or(0, |f| f.test_count),
9968        },
9969    ];
9970    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9971    for i in 0..n {
9972        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9973        if i < n - 1 {
9974            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
9975        }
9976    }
9977    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
9978    let mut metrics_tbody = String::new();
9979    for row in &rows {
9980        metrics_tbody.push_str("<tr>");
9981        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9982        for i in 0..n {
9983            write!(
9984                metrics_tbody,
9985                "<td class='mc-val-col'>{}</td>",
9986                fmt_comma(row.values[i])
9987            )
9988            .unwrap();
9989            if i < n - 1 {
9990                let d = row.seq_deltas[i];
9991                write!(
9992                    metrics_tbody,
9993                    "<td class='mc-delta-col {cls}'>{val}</td>",
9994                    cls = multi_delta_class(d),
9995                    val = multi_fmt_delta(d)
9996                )
9997                .unwrap();
9998            }
9999        }
10000        let nd = row.net_delta;
10001        write!(
10002            metrics_tbody,
10003            "<td class='mc-net-col {cls}'>{val}</td>",
10004            cls = multi_delta_class(nd),
10005            val = multi_fmt_delta(nd)
10006        )
10007        .unwrap();
10008        metrics_tbody.push_str("</tr>");
10009    }
10010    (metrics_thead, metrics_tbody)
10011}
10012
10013/// Build the JS-embeddable points JSON array for the multi-compare chart.
10014fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10015    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10016    for (i, pt) in multi.points.iter().enumerate() {
10017        let commit = pt.git_commit.as_deref().unwrap_or("");
10018        let branch = pt.git_branch.as_deref().unwrap_or("");
10019        let tags = pt.git_tags.as_deref().unwrap_or("");
10020        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10021        let scanned_ms = pt.timestamp.timestamp_millis();
10022        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10023        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10024        let commit_date = entry
10025            .and_then(|e| e.git_commit_date.as_deref())
10026            .and_then(fmt_git_date)
10027            .unwrap_or_default();
10028        let author = entry
10029            .and_then(|e| e.git_author.as_deref())
10030            .unwrap_or("")
10031            .to_string();
10032        let cov = pt
10033            .coverage_line_pct
10034            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10035        parts.push(format!(
10036            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}}}"#,
10037            run_id = js_escape(&pt.run_id),
10038            commit = js_escape(commit),
10039            branch = js_escape(branch),
10040            tags = js_escape(tags),
10041            nearest = js_escape(nearest),
10042            commit_date = js_escape(&commit_date),
10043            author = js_escape(&author),
10044            scanned = js_escape(&scanned),
10045            code = pt.code_lines,
10046            comments = pt.comment_lines,
10047            blank = pt.blank_lines,
10048            files = pt.files_analyzed,
10049            tests = pt.test_count,
10050        ));
10051    }
10052    format!("[{}]", parts.join(","))
10053}
10054
10055/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
10056fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10057    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10058    for row in &multi.file_matrix {
10059        let lang = row.language.as_deref().unwrap_or("");
10060        let codes: Vec<String> = row
10061            .code_per_scan
10062            .iter()
10063            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10064            .collect();
10065        let deltas: Vec<String> = row
10066            .code_delta_per_scan
10067            .iter()
10068            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10069            .collect();
10070        parts.push(format!(
10071            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10072            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10073            status = row.overall_status,
10074            codes = codes.join(","),
10075            deltas = deltas.join(","),
10076            total = row.total_code_delta,
10077        ));
10078    }
10079    format!("[{}]", parts.join(","))
10080}
10081
10082/// Build the column header cells for the file-matrix table.
10083fn build_mc_file_col_headers(n: usize) -> String {
10084    use std::fmt::Write as _;
10085    let mut out = String::new();
10086    for i in 0..n {
10087        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10088        if i < n - 1 {
10089            write!(
10090                out,
10091                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
10092                i + 2
10093            )
10094            .unwrap();
10095        }
10096    }
10097    out
10098}
10099
10100/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
10101fn build_mc_scope_bar(
10102    has_submodule_data: bool,
10103    sub_names: &[String],
10104    runs_csv: &str,
10105    active_sub: Option<&str>,
10106    super_scope_active: bool,
10107) -> String {
10108    use std::fmt::Write as _;
10109    if !has_submodule_data {
10110        return String::new();
10111    }
10112    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10113    let full_active = active_sub.is_none() && !super_scope_active;
10114    let mut bar = format!(
10115        r#"<div class="submod-scope-bar">
10116  <span class="submod-scope-label">
10117    <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>
10118    Scope:
10119  </span>
10120  <div class="submod-scope-divider"></div>
10121  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10122  <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>"#,
10123        full_cls = if full_active { " active" } else { "" },
10124        super_cls = if super_scope_active { " active" } else { "" },
10125    );
10126    for s in sub_names {
10127        let is_active = active_sub == Some(s.as_str());
10128        write!(
10129            bar,
10130            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10131            cls = if is_active { " active" } else { "" },
10132            name_enc = html_escape(s),
10133            name_esc = html_escape(s),
10134        )
10135        .unwrap();
10136    }
10137    bar.push_str("\n</div>");
10138    bar
10139}
10140
10141/// Build the scope-description label shown in the page subtitle.
10142fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10143    active_sub.map_or_else(
10144        || {
10145            if super_scope_active {
10146                "Super-repo only &mdash; ".to_string()
10147            } else {
10148                String::new()
10149            }
10150        },
10151        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
10152    )
10153}
10154
10155#[allow(clippy::too_many_lines)]
10156#[allow(clippy::too_many_arguments)]
10157fn multi_compare_page(
10158    multi: &MultiScanComparison,
10159    project_label: &str,
10160    version: &str,
10161    csp_nonce: &str,
10162    has_submodule_data: bool,
10163    sub_names: &[String],
10164    runs_csv: &str,
10165    super_scope_active: bool,
10166    active_sub: Option<&str>,
10167    entries: &[RegistryEntry],
10168) -> String {
10169    let n = multi.points.len();
10170    let is_many = n > 4;
10171    let mc_strip_class = if is_many {
10172        "mc-strip mc-strip-grid"
10173    } else {
10174        "mc-strip"
10175    };
10176
10177    // ── Scan strip cards ──────────────────────────────────────────────────────
10178    let scan_strip = build_mc_scan_strip(
10179        multi,
10180        entries,
10181        n,
10182        is_many,
10183        active_sub,
10184        super_scope_active,
10185        project_label,
10186    );
10187
10188    // ── Summary metrics table ─────────────────────────────────────────────────
10189    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10190
10191    // ── Chart data and table helpers ──────────────────────────────────────────
10192    let points_json = build_mc_points_json(multi, entries);
10193    let file_matrix_json = build_mc_file_matrix_json(multi);
10194
10195    // Counts for filter tabs
10196    let files_modified = multi
10197        .file_matrix
10198        .iter()
10199        .filter(|f| f.overall_status == "modified")
10200        .count();
10201    let files_added = multi
10202        .file_matrix
10203        .iter()
10204        .filter(|f| f.overall_status == "added")
10205        .count();
10206    let files_removed = multi
10207        .file_matrix
10208        .iter()
10209        .filter(|f| f.overall_status == "removed")
10210        .count();
10211    let files_unchanged = multi
10212        .file_matrix
10213        .iter()
10214        .filter(|f| f.overall_status == "unchanged")
10215        .count();
10216    let total_files = multi.file_matrix.len();
10217
10218    let file_col_headers = build_mc_file_col_headers(n);
10219    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10220    let scope_bar_html = build_mc_scope_bar(
10221        has_submodule_data,
10222        sub_names,
10223        runs_csv,
10224        active_sub,
10225        super_scope_active,
10226    );
10227    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10228    let toast_assets = sloc_toast_assets(csp_nonce);
10229
10230    format!(
10231        r#"<!doctype html>
10232<html lang="en">
10233<head>
10234  <meta charset="utf-8">
10235  <meta name="viewport" content="width=device-width, initial-scale=1">
10236  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10237  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10238  <style nonce="{csp_nonce}">
10239    :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;}}
10240    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10241    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10242    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;}}
10243    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10244    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10245    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10246    .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;}}
10247    @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));}}}}
10248    .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);}}
10249    .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;}}
10250    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10251    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10252    @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;}}}}
10253    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10254    .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));}}
10255    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10256    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10257    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10258    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10259    .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;}}
10260    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10261    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10262    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10263    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10264    .nav-dropdown{{position:relative;display:inline-flex;}}
10265    .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;}}
10266    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10267    .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;}}
10268    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10269    .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);}}
10270    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10271    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10272    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10273    body:not(.dark-theme) .icon-sun{{display:none;}}
10274    body.dark-theme .icon-moon{{display:none;}}
10275    .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;}}
10276    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10277    .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);}}
10278    .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;}}
10279    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10280    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10281    .settings-modal-body{{padding:14px 16px 16px;}}
10282    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10283    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10284    .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;}}
10285    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10286    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10287    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10288    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10289    .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;}}
10290    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10291    .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;}}
10292    .btn-back:hover{{background:var(--line);}}
10293    .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;}}
10294    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;}}
10295    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10296    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10297    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10298    .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;}}
10299    .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;}}
10300    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10301    .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;}}
10302    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10303    body.dark-theme .mc-card{{background:var(--surface-2);}}
10304    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10305    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10306    .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%;}}
10307    .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;}}
10308    .mc-card-commit:hover{{color:var(--oxide);}}
10309    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10310    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10311    .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;}}
10312    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10313    .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;}}
10314    .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;}}
10315    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10316    .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;}}
10317    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10318    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10319    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10320    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10321    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10322    .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);}}
10323    .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);}}
10324    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10325    .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;}}
10326    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10327    .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;}}
10328    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10329    .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;}}
10330    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10331    .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;}}
10332    .submod-scope-btn:hover{{background:var(--line);}}
10333    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10334    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10335    .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;}}
10336    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10337    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10338    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10339    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10340    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10341    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10342    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10343    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10344    .metrics-table .pos{{color:var(--pos);}}
10345    .metrics-table .neg{{color:var(--neg);}}
10346    .metrics-table .zero{{color:var(--muted);}}
10347    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10348    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10349    .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;}}
10350    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10351    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10352    .chart-wrap{{width:100%;overflow-x:auto;}}
10353    #mc-chart{{display:block;width:100%;}}
10354    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10355    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10356    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10357    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10358    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10359    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10360    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10361    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10362    .ic-card-h2-row .ic-card-h2{{margin:0;}}
10363    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10364    .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;}}
10365    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10366    .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;}}
10367    .ic-svg-modal-ov.open{{display:flex;}}
10368    .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);}}
10369    .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);}}
10370    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10371    .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;}}
10372    .ic-svg-modal-close:hover{{background:var(--line);}}
10373    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10374    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10375    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10376    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10377    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10378    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10379    #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;}}
10380    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10381    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10382    .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;}}
10383    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10384    .tab-btn:hover:not(.active){{background:var(--line);}}
10385    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10386    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10387    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10388    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10389    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10390    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10391    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10392    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10393    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10394    .table-wrap{{width:100%;overflow-x:auto;}}
10395    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10396    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10397    #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;}}
10398    #file-table th.left,#file-table td.left{{text-align:left;}}
10399    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10400    .file-delta-col{{color:var(--muted);font-size:11px;}}
10401    .file-net-col{{font-weight:800;}}
10402    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10403    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10404    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10405    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10406    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10407    .status-badge.modified{{background:#fff2d8;color:#926000;}}
10408    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10409    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10410    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10411    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10412    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10413    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10414    tr.row-added td{{background:rgba(26,143,71,0.04);}}
10415    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10416    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10417    tr.row-unchanged td{{color:var(--muted);}}
10418    tr.row-unchanged .status-badge{{opacity:.65;}}
10419    .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;}}
10420    .absent{{color:var(--muted);font-style:italic;}}
10421    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10422    .pagination-info{{font-size:12px;color:var(--muted);}}
10423    .pagination-btns{{display:flex;gap:5px;}}
10424    .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;}}
10425    .pg-btn:hover:not(:disabled){{background:var(--line);}}
10426    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10427    .pg-btn:disabled{{opacity:.35;cursor:default;}}
10428    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;}}
10429    .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;}}
10430    .export-btn:hover{{background:var(--line);}}
10431    .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;}}
10432    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10433    .site-footer a{{color:var(--muted);}}
10434    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;}}
10435    body.pdf-mode{{background:#fff!important;}}
10436    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10437    .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;}}
10438    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10439    .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;}}
10440    .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;}}
10441    .mc-modal-title{{font-size:18px;font-weight:800;}}
10442    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10443    .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;}}
10444    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10445    .mc-modal-body{{padding:18px 22px;}}
10446    .mc-modal-sec{{margin-bottom:20px;}}
10447    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10448    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10449    .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;}}
10450    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10451    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10452    .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;}}
10453    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10454    .mc-modal-row:last-child{{border-bottom:none;}}
10455    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10456    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10457    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10458    .mc-modal-val a:hover{{text-decoration:underline;}}
10459    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10460    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10461    .mc-modal-stat[data-tip]{{cursor:help;}}
10462    #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);}}
10463    .mc-card{{cursor:pointer;}}
10464    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10465  </style>
10466</head>
10467<body>
10468  {loading_overlay}
10469  <div class="background-watermarks" aria-hidden="true">
10470    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10471    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10472    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10473    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10474    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10475    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10476  </div>
10477  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10478  <div class="top-nav">
10479    <div class="top-nav-inner">
10480      <a class="brand" href="/">
10481        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10482        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10483      </a>
10484      <div class="nav-right">
10485        <a class="nav-pill" href="/">Home</a>
10486        <div class="nav-dropdown">
10487          <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>
10488          <div class="nav-dropdown-menu">
10489            <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>
10490          </div>
10491        </div>
10492        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10493        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10494        <div class="nav-dropdown">
10495          <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>
10496          <div class="nav-dropdown-menu">
10497            <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>
10498          </div>
10499        </div>
10500        <div class="server-status-wrap" id="server-status-wrap">
10501          <div class="nav-pill server-online-pill" id="server-status-pill">
10502            <span class="status-dot" id="status-dot"></span>
10503            <span id="server-status-label">Server</span>
10504            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10505          </div>
10506          <div class="server-status-tip">
10507            OxideSLOC is running &mdash; accessible on your network.
10508            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10509          </div>
10510        </div>
10511        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10512          <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>
10513        </button>
10514        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10515          <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>
10516          <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>
10517        </button>
10518      </div>
10519    </div>
10520  </div>
10521
10522  <div class="page">
10523    <!-- Hero header -->
10524    <div class="mc-hero">
10525      <div class="mc-hero-header">
10526        <div>
10527          <div class="mc-title">Multi-Scan Timeline</div>
10528          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10529          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10530        </div>
10531        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10532          <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>
10533          <div class="export-group" id="mc-top-export-group">
10534            <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>
10535            <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>
10536          </div>
10537        </div>
10538      </div>
10539      {scope_bar_html}
10540      <!-- Scan strip -->
10541      <div class="{mc_strip_class}">{scan_strip}</div>
10542    </div>
10543
10544    <!-- Summary metrics table -->
10545    <div class="panel">
10546      <div class="panel-title">Metric Progression</div>
10547      <div class="table-wrap">
10548        <table class="metrics-table">
10549          <thead>{metrics_thead}</thead>
10550          <tbody>{metrics_tbody}</tbody>
10551        </table>
10552      </div>
10553    </div>
10554
10555    <!-- Scan Charts -->
10556    <div class="panel" id="mc-charts-panel">
10557      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10558      <div class="ic-grid">
10559        <!-- Timeline line chart — spans full width -->
10560        <div class="ic-card" style="grid-column:span 2">
10561          <div class="ic-card-h2-row">
10562            <span class="ic-card-h2">Timeline</span>
10563            <div class="chart-toolbar" style="margin:0">
10564              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10565              <button class="chart-metric-btn" data-metric="files">Files</button>
10566              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10567              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10568              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10569            </div>
10570          </div>
10571          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10572        </div>
10573        <!-- Code Metrics: Scan 1 vs Latest -->
10574        <div class="ic-card">
10575          <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>
10576          <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>
10577          <div id="mc-ic-c1"></div>
10578        </div>
10579        <!-- Language Code Delta -->
10580        <div class="ic-card" id="mc-ic-lang-card">
10581          <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>
10582          <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>
10583          <div id="mc-ic-c3"></div>
10584        </div>
10585        <!-- Delta by Metric -->
10586        <div class="ic-card">
10587          <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>
10588          <div id="mc-ic-c2"></div>
10589        </div>
10590        <!-- File Change Distribution -->
10591        <div class="ic-card">
10592          <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>
10593          <div id="mc-ic-c4"></div>
10594        </div>
10595      </div>
10596    </div>
10597
10598    <!-- File matrix table -->
10599    <div class="panel">
10600      <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>
10601      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10602        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10603          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10604          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10605          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10606          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10607          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10608        </div>
10609        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10610          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10611          <div class="export-group">
10612          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10613          <button type="button" class="export-btn" id="export-csv-btn">
10614            <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>
10615            CSV
10616          </button>
10617          <button type="button" class="export-btn" id="mc-file-xls-btn">
10618            <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>
10619            Excel
10620          </button>
10621          </div>
10622        </div>
10623      </div>
10624      <div class="table-wrap">
10625        <table id="file-table">
10626          <thead>
10627            <tr>
10628              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10629              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10630              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10631              {file_col_headers}
10632              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10633            </tr>
10634          </thead>
10635          <tbody id="file-tbody"></tbody>
10636        </table>
10637      </div>
10638      <div class="pagination">
10639        <span class="pagination-info" id="pg-info"></span>
10640        <div class="pagination-btns" id="pg-btns"></div>
10641        <div style="display:flex;align-items:center;gap:6px;">
10642          <span style="font-size:12px;color:var(--muted)">Show</span>
10643          <select class="per-page" id="per-page-sel">
10644            <option value="25" selected>25 per page</option>
10645            <option value="50">50 per page</option>
10646            <option value="100">100 per page</option>
10647          </select>
10648        </div>
10649      </div>
10650    </div>
10651  </div>
10652
10653  <div id="mc-ic-tt"></div>
10654
10655  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10656    <div class="ic-svg-modal">
10657      <div class="ic-svg-modal-hdr">
10658        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10659        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10660      </div>
10661      <div id="ic-svg-modal-body"></div>
10662    </div>
10663  </div>
10664
10665  <footer class="site-footer">
10666    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10667    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10668    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10669    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10670    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10671  </footer>
10672
10673  <script nonce="{csp_nonce}">
10674  (function(){{
10675    // ── Dark theme ───────────────────────────────────────────────────────────
10676    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10677    var renderInlineCharts=null;
10678    var tt=document.getElementById('theme-toggle');
10679    if(tt)tt.addEventListener('click',function(){{
10680      var on=document.body.classList.toggle('dark-theme');
10681      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10682      renderChart(activeMetric);
10683      if(renderInlineCharts)renderInlineCharts();
10684    }});
10685
10686    // ── Code particles ───────────────────────────────────────────────────────
10687    var container=document.getElementById('code-particles');
10688    if(container){{
10689      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()'];
10690      for(var i=0;i<28;i++){{
10691        (function(idx){{
10692          var el=document.createElement('span');el.className='code-particle';
10693          el.textContent=snips[idx%snips.length];
10694          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10695          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10696          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10697          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10698          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10699          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10700          container.appendChild(el);
10701        }})(i);
10702      }}
10703    }}
10704
10705    // ── Watermarks ───────────────────────────────────────────────────────────
10706    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10707    if(wms.length){{
10708      var placed=[];
10709      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;}}
10710      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];}}
10711      var half=Math.floor(wms.length/2);
10712      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;}});
10713    }}
10714
10715    // ── Settings / colour scheme modal ───────────────────────────────────────
10716    (function(){{
10717      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'}}];
10718      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);}});}}
10719      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10720      function init(){{
10721        var btn=document.getElementById('settings-btn');if(!btn)return;
10722        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10723        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>';
10724        document.body.appendChild(m);
10725        var g=document.getElementById('scheme-grid');
10726        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);}});
10727        var cl=document.getElementById('settings-close-btn');
10728        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');}});
10729        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10730        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10731      }}
10732      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10733    }})();
10734
10735    // ── Timezone support for scan timestamps ─────────────────────────────────
10736    (function(){{
10737      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);}};
10738      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'';}}}};
10739      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);}});}};
10740      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10741      window.applyTz(storedTz);
10742      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);}});}}}}
10743      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10744    }})();
10745
10746    // ── Data ────────────────────────────────────────────────────────────────
10747    var POINTS={points_json};
10748    var FILES={file_matrix_json};
10749    var N={n};
10750
10751    // ── fmt helper ───────────────────────────────────────────────────────────
10752    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();}}
10753    function fmtFull(n){{return Number(n).toLocaleString();}}
10754    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10755
10756    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10757    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10758    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));}}
10759    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10760    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10761
10762    // ── Timeline chart ───────────────────────────────────────────────────────
10763    var activeMetric='code';
10764    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10765    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10766
10767    function renderChart(metric){{
10768      var svg=document.getElementById('mc-chart');if(!svg)return;
10769      var W=svg.getBoundingClientRect().width||800,H=280;
10770      svg.setAttribute('height',H);
10771      var pad={{l:62,r:20,t:32,b:72}};
10772      var dark=document.body.classList.contains('dark-theme');
10773      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10774      var valid=pts.filter(function(v){{return v!=null;}});
10775      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;}}
10776      var minV=0,maxV=Math.max.apply(null,valid);
10777      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10778      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10779      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10780      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10781      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10782      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10783      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10784      var parts=[];
10785      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10786      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>');}}
10787      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10788      var lineD='';var firstPt=true;
10789      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);}}}}
10790      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10791      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10792      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10793      for(var i=0;i<N;i++){{
10794        if(pts[i]==null)continue;
10795        var cx=xOf(i),cy=yOf(pts[i]);
10796        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10797        var hasTag=p.tags&&p.tags.length>0;
10798        // Permanent Y-value label above the dot
10799        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>');
10800        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+'"/>');
10801        var xanchor=i===0?'start':i===N-1?'end':'middle';
10802        // X-axis label at 2× the original size (18 px)
10803        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>');
10804      }}
10805      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10806      svg.setAttribute('viewBox','0 0 '+W+' '+H);
10807      svg.innerHTML=parts.join('');
10808      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');}});
10809      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10810      svg.onmousemove=function(e){{
10811        var rect=svg.getBoundingClientRect();
10812        var scaleX=W/rect.width;
10813        var mouseX=(e.clientX-rect.left)*scaleX;
10814        var nearest=-1,minDist=Infinity;
10815        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;}}}}
10816        if(nearest<0)return;
10817        var nc=xOf(nearest),ny=yOf(pts[nearest]);
10818        var xhair=svg.querySelector('.mc-xhair');
10819        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10820        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"/>';
10821        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10822        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10823        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>';
10824        var bx=rect.left+(nc/W*rect.width)+18;
10825        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10826        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10827      }};
10828      svg.onmouseleave=function(){{
10829        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10830        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10831      }};
10832    }}
10833
10834    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10835
10836    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10837      btn.addEventListener('click',function(){{
10838        activeMetric=this.dataset.metric;
10839        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10840        this.classList.add('active');
10841        renderChart(activeMetric);
10842      }});
10843    }});
10844    if(typeof ResizeObserver!=='undefined'){{
10845      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10846    }}
10847    renderChart(activeMetric);
10848
10849    // ── File matrix table ────────────────────────────────────────────────────
10850    var activeStatus='';
10851    var currentPage=1;
10852    var perPage=25;
10853    var mcSortCol=null,mcSortAsc=true;
10854
10855    function getFiltered(){{
10856      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10857      if(!mcSortCol)return data;
10858      var asc=mcSortAsc;
10859      return data.slice().sort(function(a,b){{
10860        var va,vb;
10861        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10862        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10863        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10864        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10865        else{{return 0;}}
10866        if(asc)return va<vb?-1:va>vb?1:0;
10867        return va<vb?1:va>vb?-1:0;
10868      }});
10869    }}
10870
10871    function renderFilePage(){{
10872      var filtered=getFiltered();
10873      var total=filtered.length;
10874      var totalPages=Math.max(1,Math.ceil(total/perPage));
10875      if(currentPage>totalPages)currentPage=totalPages;
10876      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10877      var tbody=document.getElementById('file-tbody');if(!tbody)return;
10878      var rows=[];
10879      for(var i=start;i<end;i++){{
10880        var f=filtered[i];
10881        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10882        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10883        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10884        for(var j=0;j<N;j++){{
10885          var cv=f.c[j];
10886          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10887          if(j<N-1){{
10888            var dv=f.d[j+1];
10889            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10890              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10891          }}
10892        }}
10893        var tc=f.t;
10894        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10895        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10896      }}
10897      tbody.innerHTML=rows.join('');
10898
10899      var info=document.getElementById('pg-info');
10900      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10901      renderPgBtns(totalPages);
10902    }}
10903
10904    function renderPgBtns(totalPages){{
10905      var wrap=document.getElementById('pg-btns');if(!wrap)return;
10906      var btns=[];
10907      function mkBtn(label,page,active,disabled){{
10908        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10909        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10910      }}
10911      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
10912      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10913      if(s>1)btns.push(mkBtn('1',1,false,false));
10914      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10915      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10916      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10917      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10918      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
10919      wrap.innerHTML=btns.join('');
10920      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10921        b.addEventListener('click',function(){{
10922          var pg=parseInt(this.dataset.pg,10);
10923          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10924        }});
10925      }});
10926    }}
10927
10928    // Tab filter
10929    document.querySelectorAll('.tab-btn').forEach(function(btn){{
10930      btn.addEventListener('click',function(){{
10931        activeStatus=this.dataset.status||'';
10932        currentPage=1;
10933        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10934        this.classList.add('active');
10935        renderFilePage();
10936      }});
10937    }});
10938
10939    // Per-page selector
10940    var ppSel=document.getElementById('per-page-sel');
10941    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10942
10943    // ── Column header sort ───────────────────────────────────────────────────
10944    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10945      th.addEventListener('click',function(){{
10946        var col=th.dataset.sortCol;
10947        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10948        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10949          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10950        }});
10951        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10952        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
10953        currentPage=1;renderFilePage();
10954      }});
10955    }});
10956
10957    // Reset button also clears sort
10958    var mcResetBtn=document.getElementById('mc-file-reset-btn');
10959    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10960      mcSortCol=null;mcSortAsc=true;
10961      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10962        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10963      }});
10964      activeStatus='';currentPage=1;
10965      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10966      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10967      renderFilePage();
10968    }});
10969
10970    renderFilePage();
10971
10972    // ── CSV export ───────────────────────────────────────────────────────────
10973    var exportBtn=document.getElementById('export-csv-btn');
10974    if(exportBtn)exportBtn.addEventListener('click',function(){{
10975      var header=['File','Language','Status'];
10976      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10977      header.push('Net Delta');
10978      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10979      var filtered=getFiltered();
10980      filtered.forEach(function(f){{
10981        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10982        for(var j=0;j<N;j++){{
10983          cols.push(f.c[j]!=null?f.c[j]:'');
10984          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10985        }}
10986        cols.push(f.t);
10987        rows.push(cols.join(','));
10988      }});
10989      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10990      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10991      a.download=mcExportName('csv');a.click();
10992    }});
10993
10994    // ── File matrix extra export buttons ─────────────────────────────────────
10995    (function(){{
10996      var resetBtn=document.getElementById('mc-file-reset-btn');
10997      if(resetBtn)resetBtn.addEventListener('click',function(){{
10998        activeStatus='';currentPage=1;
10999        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11000        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11001        renderFilePage();
11002      }});
11003
11004      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11005      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11006      function mcMakeXlsx(fname){{
11007        var filtered=getFiltered();
11008        var enc=new TextEncoder();
11009        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;}}
11010        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;}}
11011        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11012        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11013        var ss=[],si={{}};
11014        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11015        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11016        function WS(){{
11017          var R=0,buf=[];
11018          function cl(c){{return String.fromCharCode(65+c);}}
11019          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11020          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11021          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11022          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>';}}
11023          return{{sc:sc,nc:nc,row:row,xml:xml}};
11024        }}
11025        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11026        var proj=mcExportProj();
11027        // \u2500\u2500 Summary sheet \u2500\u2500
11028        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11029        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11030        r1(s1(0,proj,2));
11031        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11032        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
11033        r1('');
11034        r1(s1(0,'SCAN SUMMARY',8));
11035        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));
11036        POINTS.forEach(function(p,i){{
11037          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11038          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));
11039        }});
11040        r1('');
11041        if(POINTS.length>1){{
11042          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11043          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11044          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11045          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)));}};
11046          nr('Code Lines',pf.code,pl.code);
11047          nr('Comment Lines',pf.comments,pl.comments);
11048          nr('Files Analyzed',pf.files,pl.files);
11049          nr('Tests',pf.tests,pl.tests);
11050          r1('');
11051        }}
11052        var cMod=0,cAdd=0,cRem=0,cUnch=0;
11053        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11054        var totF=FILES.length||1;
11055        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11056        r1(s1(0,'FILE CHANGES',8));
11057        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11058        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11059        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11060        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11061        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11062        r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11063        var lm={{}};
11064        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;}});
11065        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11066        if(langs.length){{
11067          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11068          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11069          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)));}});
11070        }}
11071        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11072        // \u2500\u2500 File Delta sheet \u2500\u2500
11073        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11074        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11075        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);}}
11076        hcells+=s2(hc,'Net Delta',3);
11077        r2(hcells);
11078        filtered.forEach(function(f){{
11079          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11080          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));}}}}
11081          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11082          r2(cells);
11083        }});
11084        var ncols=3+N+(N-1)+1;
11085        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11086        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>';
11087        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11088        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>',
11089          '_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>',
11090          '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>',
11091          '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>',
11092          '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>',
11093          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11094        var zparts=[],zcds=[],zoff=0,znf=0;
11095        ['[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){{
11096          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11097          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]);
11098          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);
11099          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));
11100          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11101          zoff+=entry.length;znf++;
11102        }});
11103        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11104        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]);
11105        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11106        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11107        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11108        out.set(new Uint8Array(eocd),pos);
11109        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11110        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11111      }}
11112
11113      var xlsBtn=document.getElementById('mc-file-xls-btn');
11114      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11115
11116      // File matrix HTML export — interactive: sort by column, filter by status
11117      function mcFileBuildHtml(){{
11118        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11119        var hdrs=['File','Language','Status'];
11120        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11121        hdrs.push('Net \u0394');
11122        var SI=2;
11123        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;}});
11124        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11125        var cnt={{all:allRows.length}};
11126        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11127        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11128        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11129          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11130          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11131          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11132          '.sub{{font-size:12px;color:#99aabb;}}'+
11133          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11134          '.wr{{padding:16px 20px;}}'+
11135          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11136          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11137          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11138          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11139          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11140          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11141          'thead tr{{background:#1a2035;}}'+
11142          '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;}}'+
11143          'th:hover{{background:#2a3050;}}'+
11144          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11145          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11146          'tr:nth-child(even) td{{background:#faf7f4;}}'+
11147          'tr:hover td{{background:#f5f0ea;}}'+
11148          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11149          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11150        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11151        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11152          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11153          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11154          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11155          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11156        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11157          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
11158          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
11159          '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>";}}'+
11160          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11161          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11162          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
11163          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11164          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11165          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11166          '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("")'+
11167          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11168          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11169          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11170          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11171          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11172          'sd=(sc===ci)?-sd:1;sc=ci;'+
11173          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11174          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11175          'render();';
11176        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11177          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11178          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
11179          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11180          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11181          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11182          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11183          '<script>'+inlineJs+'<\/script><\/body><\/html>';
11184      }}
11185
11186      var htmlBtn=document.getElementById('mc-file-html-btn');
11187      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11188        var h=mcFileBuildHtml();
11189        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11190        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11191        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11192      }});
11193
11194      var pdfBtn=document.getElementById('mc-file-pdf-btn');
11195      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11196        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11197      }});
11198    }})();
11199
11200    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11201    (function(){{
11202      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11203      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11204      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11205      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11206      function fmt2(n){{return Number(n).toLocaleString();}}
11207      function px(n){{return Math.round(n);}}
11208      var _tt=document.getElementById('mc-ic-tt');
11209      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11210      function addTT(el){{
11211        if(!el)return;
11212        el.addEventListener('mouseover',function(e){{
11213          var t=e.target.closest('[data-ttl]');
11214          if(t&&_tt){{
11215            var ttl=t.getAttribute('data-ttl');
11216            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11217            _tt.style.display='block';mvTT(e);
11218            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11219            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11220          }} else {{
11221            if(_tt)_tt.style.display='none';
11222            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11223          }}
11224        }});
11225        el.addEventListener('mouseleave',function(){{
11226          if(_tt)_tt.style.display='none';
11227          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11228        }});
11229        el.addEventListener('mousemove',function(e){{mvTT(e);}});
11230      }}
11231      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';}}
11232      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11233      function buildCharts(){{
11234        if(N<2)return;
11235        var cs=getComputedStyle(document.body);
11236        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11237        var textCol=cv('--text','#43342d');
11238        var mutedCol=cv('--muted','#7b675b');
11239        var gFill=cv('--muted-2','#a08777');
11240        var LGY=cv('--line','#e6d0bf');
11241        var axisCol=cv('--line-strong','#d8bfad');
11242        var surf2col=cv('--surface-2','#f4ede4');
11243        var surfCol=cv('--surface','#fff8f0');
11244        var p0=POINTS[0],pLast=POINTS[N-1];
11245        var dark=document.body.classList.contains('dark-theme');
11246        var FADE=dark?'#524238':'#e6d0bf';
11247        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11248        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;}}
11249      var c1mets=[
11250        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11251        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11252        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11253      ];
11254      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11255      // Code Metrics chart — grows to fill the height its grid row settled to (the
11256      // Language Code Delta sibling usually drives that), so it never sits short at
11257      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11258      function drawC1(){{
11259        var C1W=620,C1H=200;
11260        var c1host=document.getElementById('mc-ic-c1');
11261        var c1card=c1host?c1host.closest('.ic-card'):null;
11262        if(c1host&&c1card&&c1host.clientWidth>0){{
11263          var avW=c1host.clientWidth;
11264          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11265          var wantH=availPx*C1W/avW;
11266          if(wantH>C1H)C1H=wantH;
11267        }}
11268        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11269        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11270        for(var gi=1;gi<=4;gi++){{
11271          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11272          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11273          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11274        }}
11275        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11276        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11277        c1mets.forEach(function(m,i){{
11278          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11279          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11280          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11281          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;"/>';
11282          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>';
11283          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;"/>';
11284          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>';
11285          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>';
11286          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>';
11287        }});
11288        c1+='</svg>';
11289        return c1;
11290      }}
11291      // Chart 2: Delta by Metric (net delta first scan to last)
11292      var mets=[
11293        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11294        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11295        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11296      ];
11297      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11298      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;
11299      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11300      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11301      mets.forEach(function(m,i){{
11302        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);
11303        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>';
11304        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;"/>';
11305        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>';}}
11306        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>';}}
11307      }});
11308      c2+='</svg>';
11309      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11310      var lm={{}};
11311      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;}});
11312      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11313      function drawC3(){{
11314        if(!langs.length)return'';
11315        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11316        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;
11317        var c3host=document.getElementById('mc-ic-c3');
11318        var c3card=document.getElementById('mc-ic-lang-card');
11319        var C3H=langs.length*30+24;
11320        if(c3host&&c3card&&c3host.clientWidth>0){{
11321          var avW=c3host.clientWidth;
11322          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11323          var wantH=availPx*C3W/avW;
11324          if(wantH>C3H)C3H=wantH;
11325        }}
11326        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11327        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11328        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11329        langs.forEach(function(l,i){{
11330          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);
11331          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11332          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"/>';
11333          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>';}}
11334          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>';}}
11335          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>';
11336        }});
11337        c3+='</svg>';
11338        return c3;
11339      }}
11340      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11341      var fm=0,fa=0,fr=0,fu=0;
11342      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11343      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;}});
11344      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11345      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11346      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11347      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;
11348      if(segs.length===1){{
11349        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"/>';
11350        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11351        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>';
11352      }} else {{
11353        segs.forEach(function(s){{
11354          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11355          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);
11356          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);
11357          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"/>';
11358          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>';}}
11359          ang4+=sw;
11360        }});
11361      }}
11362      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11363      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11364      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11365      segs.forEach(function(s,i){{
11366        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11367        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;"/>';
11368        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>';
11369        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11370      }});
11371      c4+='</svg>';
11372      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11373      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11374      // once at natural height to seed the row, then both are filled to the row the
11375      // grid settled to, so neither sits short at the top of an over-tall cell.
11376      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11377      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11378      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11379      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11380      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>';
11381      if(e1)e1.innerHTML=drawC1();
11382      }}
11383      buildCharts();
11384      renderInlineCharts=buildCharts;
11385      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11386      (function(){{
11387        var ov=document.getElementById('ic-svg-modal-ov');
11388        var body=document.getElementById('ic-svg-modal-body');
11389        var ttl=document.getElementById('ic-svg-modal-title');
11390        var closeBtn=document.getElementById('ic-svg-modal-close');
11391        if(!ov||!body)return;
11392        function close(){{ov.classList.remove('open');body.innerHTML='';}}
11393        function open(srcId,title){{
11394          var src=document.getElementById(srcId);if(!src)return;
11395          ttl.textContent=title||'';
11396          var card=src.closest('.ic-card');
11397          var legHtml='';
11398          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11399          body.innerHTML=legHtml+src.innerHTML;
11400          var svg=body.querySelector('svg');
11401          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11402          addTT(body);
11403          ov.classList.add('open');
11404        }}
11405        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11406          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11407        }});
11408        if(closeBtn)closeBtn.addEventListener('click',close);
11409        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11410        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11411      }})();
11412
11413      // HTML legend hover → highlight matching SVG bars within the SAME card only
11414      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11415        var metric=leg.getAttribute('data-highlight');
11416        var parentCard=leg.closest('.ic-card');
11417        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11418        if(!chartEl)return;
11419        leg.addEventListener('mouseenter',function(){{
11420          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11421            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11422              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11423              x.style.opacity='1';
11424            }} else {{
11425              x.style.opacity='0.28';
11426            }}
11427          }});
11428        }});
11429        leg.addEventListener('mouseleave',function(){{
11430          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11431        }});
11432      }});
11433      // Author handles
11434      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11435
11436      // ── Export helpers ────────────────────────────────────────────────────────
11437      // Fetch one image from the server and return a data-URI Promise
11438      function mcFetchUri(path){{
11439        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11440          return new Promise(function(res){{
11441            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11442          }});
11443        }}).catch(function(){{return '';}});
11444      }}
11445      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11446      function mcInlineImgs(html,cb){{
11447        var paths=[],seen={{}};
11448        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11449        if(!paths.length){{cb(html);return;}}
11450        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11451          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11452          .catch(function(){{cb(html);}});
11453      }}
11454      // Capture full-page HTML with all table rows visible
11455      function mcRawHtml(pdfMode){{
11456        if(pdfMode)document.body.classList.add('pdf-mode');
11457        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11458        var html=document.documentElement.outerHTML;
11459        perPage=s;currentPage=p;renderFilePage();
11460        if(pdfMode)document.body.classList.remove('pdf-mode');
11461        return html;
11462      }}
11463
11464      // HTML export (full page with inlined images)
11465      function mcDoHtml(btn,fname){{
11466        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11467        mcInlineImgs(mcRawHtml(false),function(html){{
11468          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11469          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11470          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11471          btn.disabled=false;btn.innerHTML=orig;
11472        }});
11473      }}
11474      // PDF export — comprehensive document-style report: full numbers, all sections
11475      function mcBuildPdfHtml(){{
11476        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11477        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11478        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11479        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>';}}
11480        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11481        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11482        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)));}}
11483        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11484        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11485        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11486        // Header/footer flow in document order (NOT position:fixed) — a fixed
11487        // header repeats every printed page in Chromium and overlaps the content
11488        // below it, swallowing the first rows of pages 2+ and clipping the cards
11489        // on page 1. The table <thead> repeats per page natively, so every row
11490        // stays visible.
11491        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11492          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11493          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11494          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11495          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11496          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11497          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11498          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11499          '.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;}}'+
11500          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11501          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11502          '.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;}}'+
11503          '.body{{padding:12px 18px 0;}}'+
11504          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11505          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11506          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11507          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11508          '.sec{{margin-bottom:10px;}}'+
11509          '.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;}}'+
11510          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11511          '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;}}'+
11512          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11513          'tr:nth-child(even) td{{background:#faf8f6;}}';
11514        // ── Metric Progression ────────────────────────────────────────────────
11515        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11516        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11517        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>';
11518        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11519        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11520        var progRows=POINTS.map(function(pt,i){{
11521          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)));
11522          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11523            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11524            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11525            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11526            '<td style="text-align:right">'+full(pt.files)+'</td>';
11527          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11528          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11529          return r+'</tr>';
11530        }}).join('');
11531        // ── Scan-to-scan changes ──────────────────────────────────────────────
11532        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11533          var prev=POINTS[i];
11534          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11535          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11536          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11537            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11538            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11539            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11540            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11541        }}).join(''):'';
11542        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11543        var fmSection='';
11544        if(FILES&&FILES.length){{
11545          // Hard cap on per-scan columns so the table never overflows the page width.
11546          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11547          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11548          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11549          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11550          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11551          var fmRows=topFiles.map(function(f){{
11552            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11553            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>';
11554            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11555            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11556            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>';
11557          }}).join('');
11558          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11559          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11560            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11561        }}
11562        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11563          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11564          '<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>'+
11565
11566          '<div class="body">'+
11567          '<div class="sg">'+
11568          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11569            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11570          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11571            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11572          (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>':
11573            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11574          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11575          '</div>'+
11576          '<div class="sec"><p class="sh">Metric Progression</p>'+
11577          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11578          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11579          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11580          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11581          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11582          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11583          fmSection+
11584          '</div>'+
11585          '<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>'+
11586          '</body></html>';
11587      }}
11588      function mcDoPdf(btn){{
11589        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11590      }}
11591
11592      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11593      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11594      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11595      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11596      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11597      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11598      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11599      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11600      if(location.protocol==='file:'){{
11601        [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';}}}} );
11602        [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';}}}} );
11603      }}
11604    }})();
11605    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11606    (function(){{
11607      function $(id){{return document.getElementById(id);}}
11608      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11609      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11610      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11611      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11612      function openModal(idx){{
11613        var ov=$('mc-modal-overlay');if(!ov)return;
11614        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11615        if(idx<0||idx>=N)return;
11616        var pt=POINTS[idx];
11617        titleEl.textContent='Scan '+(idx+1);
11618        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11619        subEl.textContent=lbl;
11620        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11621          '<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>'+
11622          '<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>'+
11623          '<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>'+
11624          '<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>'+
11625          (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>':'')+
11626          (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>':'')+
11627          '</div></div>';
11628        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11629          (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>':'')+
11630          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11631          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11632          (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>':'')+
11633          (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>':'')+
11634          (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>':'')+
11635          (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>':'')+
11636          '<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>'+
11637          '</div>';
11638        var dHtml='';
11639        if(idx>0){{
11640          var prev=POINTS[idx-1];
11641          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11642          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11643            '<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>'+
11644            '<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>'+
11645            '<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>'+
11646            '</div></div>';
11647        }}
11648        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11649        ov.classList.add('open');document.body.style.overflow='hidden';
11650      }}
11651      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11652      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11653      document.addEventListener('click',function(e){{
11654        if(!e.target||!e.target.closest)return;
11655        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11656        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11657        var card=e.target.closest('.mc-card');
11658        if(!card)return;
11659        if(e.target.closest('a'))return;
11660        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11661        var i=cards.indexOf(card);
11662        if(i>=0)openModal(i);
11663      }});
11664      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11665      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11666      var statTip=null;
11667      document.addEventListener('mousemove',function(e){{
11668        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11669        if(!box){{if(statTip)statTip.style.display='none';return;}}
11670        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11671        var tip=box.getAttribute('data-tip')||'';
11672        if(statTip.textContent!==tip)statTip.textContent=tip;
11673        statTip.style.display='block';
11674        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11675        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11676        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11677        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11678      }});
11679      (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');}})();
11680    }})();
11681  }})();
11682  </script>
11683  <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]';
11684  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;}}
11685  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>
11686  <!-- Scan card detail modal -->
11687  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11688    <div class="mc-modal" id="mc-modal">
11689      <div class="mc-modal-head">
11690        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11691        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11692      </div>
11693      <div class="mc-modal-body" id="mc-modal-body"></div>
11694    </div>
11695  </div>
11696  {toast_assets}
11697</body>
11698</html>"#,
11699        project_label = html_escape(project_label),
11700        n = n,
11701        scan_strip = scan_strip,
11702        mc_strip_class = mc_strip_class,
11703        metrics_thead = metrics_thead,
11704        metrics_tbody = metrics_tbody,
11705        file_col_headers = file_col_headers,
11706        total_files = total_files,
11707        files_modified = files_modified,
11708        files_added = files_added,
11709        files_removed = files_removed,
11710        files_unchanged = files_unchanged,
11711        points_json = points_json,
11712        file_matrix_json = file_matrix_json,
11713        nav_compare_active = nav_compare_active,
11714        version = version,
11715        csp_nonce = csp_nonce,
11716        scope_bar_html = scope_bar_html,
11717        scope_label = scope_label,
11718        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11719    )
11720}
11721
11722// ── Trend report page ─────────────────────────────────────────────────────────
11723// Protected. Interactive time-series chart page that loads scan history via
11724// /api/metrics/history and renders a vanilla-SVG line chart.
11725//
11726// GET /trend-reports
11727
11728#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11729async fn trend_report_handler(
11730    State(state): State<AppState>,
11731    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11732) -> Response {
11733    auto_scan_watched_dirs(&state).await;
11734
11735    let watched_dirs_list: Vec<String> = {
11736        let wd = state.watched_dirs.lock().await;
11737        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11738    };
11739
11740    // Collect distinct project roots for the root selector dropdown.
11741    let roots: Vec<String> = {
11742        let reg = state.registry.lock().await;
11743        let mut seen = std::collections::BTreeSet::new();
11744        reg.entries
11745            .iter()
11746            .flat_map(|e| e.input_roots.iter().cloned())
11747            .filter(|r| seen.insert(r.clone()))
11748            .collect()
11749    };
11750
11751    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11752    let nonce = &csp_nonce;
11753    let version = env!("CARGO_PKG_VERSION");
11754    let toast_assets = sloc_toast_assets(nonce);
11755
11756    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11757    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11758    // of interactive controls — folder watching is managed by the host administrator.
11759    let watched_dirs_html: String = if state.server_mode {
11760        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()
11761    } else {
11762        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11763            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11764                .to_string()
11765        } else {
11766            watched_dirs_list
11767                .iter()
11768                .fold(String::new(), |mut s, d| {
11769                    use std::fmt::Write as _;
11770                    let escaped =
11771                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
11772                    write!(
11773                        s,
11774                        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>"#
11775                    ).expect("write to String is infallible");
11776                    s
11777                })
11778        };
11779        format!(
11780            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>"#
11781        )
11782    };
11783
11784    let html = format!(
11785        r##"<!doctype html>
11786<html lang="en">
11787<head>
11788  <meta charset="utf-8" />
11789  <meta name="viewport" content="width=device-width, initial-scale=1" />
11790  <title>OxideSLOC | Trend Reports</title>
11791  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11792  <style nonce="{nonce}">
11793    :root {{
11794      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11795      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11796      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11797      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11798      --info-bg:#eef3ff; --info-text:#4467d8;
11799    }}
11800    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11801    *{{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;}}
11802    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11803    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11804    .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;}}
11805    @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));}}}}
11806    .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);}}
11807    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11808    .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));}}
11809    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11810    .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;}}
11811    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11812    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11813    @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; }} }}
11814    .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;}}
11815    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11816    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11817    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11818    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11819    .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;}}
11820    .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;}}
11821    .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;}}
11822    .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;}}
11823    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11824    .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);}}
11825    .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;}}
11826    .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;}}
11827    .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;}}
11828    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11829    .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;}}
11830    .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);}}
11831    .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;}}
11832    .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;}}
11833    .tz-select:focus{{border-color:var(--oxide);}}
11834    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11835    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11836    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11837    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11838    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11839    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11840    .trend-title-block{{flex:1;min-width:0;}}
11841    .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;}}
11842    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11843    .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;}}
11844    .chart-select:focus{{border-color:var(--accent);}}
11845    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11846    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11847    .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);}}
11848    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11849    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11850    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11851    .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);}}
11852    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11853    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11854    .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;}}
11855    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11856    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11857    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11858    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11859    .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;}}
11860    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11861    .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;}}
11862    .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);}}
11863    .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;}}
11864    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11865    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11866    .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);}}
11867    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11868    .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;}}
11869    .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;}}
11870    .data-table tr:last-child td{{border-bottom:none;}}
11871    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11872    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11873    .table-wrap{{width:100%;overflow-x:auto;}}
11874    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11875    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11876    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11877    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11878    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11879    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11880    .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;}}
11881    .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;}}
11882    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11883    .pagination-info{{font-size:13px;color:var(--muted);}}
11884    .pagination-btns{{display:flex;gap:6px;}}
11885    .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;}}
11886    .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;}}
11887    #scan-history-table col:nth-child(1){{width:155px;}}
11888    #scan-history-table col:nth-child(2){{width:240px;}}
11889    #scan-history-table col:nth-child(3){{width:82px;}}
11890    #scan-history-table col:nth-child(4){{width:82px;}}
11891    #scan-history-table col:nth-child(5){{width:90px;}}
11892    #scan-history-table col:nth-child(6){{width:90px;}}
11893    #scan-history-table col:nth-child(7){{width:88px;}}
11894    #scan-history-table col:nth-child(8){{width:150px;}}
11895    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11896    .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;}}
11897    .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;}}
11898    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11899    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11900    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11901    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11902    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11903    .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;}}
11904    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11905    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11906    .watched-chip-rm:hover{{color:var(--oxide);}}
11907    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11908    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11909    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11910    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11911    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11912    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11913    a.run-link:hover{{text-decoration:underline;}}
11914    .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);}}
11915    .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);}}
11916    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11917    .metric-num{{font-weight:700;color:var(--text);}}
11918    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11919    .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;}}
11920    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11921    .btn.primary:hover{{opacity:.9;}}
11922    .rpt-btn{{min-width:58px;justify-content:center;}}
11923    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11924    .report-cell{{overflow:visible!important;white-space:normal!important;}}
11925    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11926    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11927    .submod-details summary::-webkit-details-marker{{display:none;}}
11928    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11929    .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;}}
11930    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11931    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11932    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11933    .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;}}
11934    .export-btn:hover{{background:var(--line);}}
11935    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11936    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11937    .site-footer a{{color:var(--muted);}}
11938    .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;}}
11939    .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;}}
11940    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11941    /* Modal system (Retention Policy / Clean-up) */
11942    .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;}}
11943    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11944    .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);}}
11945    .tr-modal{{background:rgba(255,255,255,0.90);}}
11946    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11947    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11948    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11949    .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);}}
11950    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11951    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11952    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11953    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11954    .tr-modal-body{{padding:22px 30px;}}
11955    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11956    .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;}}
11957    .tr-btn:hover{{transform:translateY(-1px);}}
11958    .tr-btn:active{{transform:translateY(0);}}
11959    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11960    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11961    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11962    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11963    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11964    .tr-btn-secondary:hover{{background:var(--line);}}
11965    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11966    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11967  </style>
11968</head>
11969<body>
11970  <div class="background-watermarks" aria-hidden="true">
11971    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11972    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11973    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11974    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11975    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11976    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11977  </div>
11978  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11979  <div class="top-nav">
11980    <div class="top-nav-inner">
11981      <a class="brand" href="/">
11982        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11983        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11984      </a>
11985      <div class="nav-right">
11986        <a class="nav-pill" href="/">Home</a>
11987        <div class="nav-dropdown">
11988          <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>
11989          <div class="nav-dropdown-menu">
11990            <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>
11991          </div>
11992        </div>
11993        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11994        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11995        <div class="nav-dropdown">
11996          <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>
11997          <div class="nav-dropdown-menu">
11998            <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>
11999          </div>
12000        </div>
12001        <div class="server-status-wrap" id="server-status-wrap">
12002          <div class="nav-pill server-online-pill" id="server-status-pill">
12003            <span class="status-dot" id="status-dot"></span>
12004            <span id="server-status-label">Server</span>
12005            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12006          </div>
12007          <div class="server-status-tip">
12008            OxideSLOC is running — accessible on your network.
12009            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12010          </div>
12011        </div>
12012        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12013          <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>
12014        </button>
12015        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12016          <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>
12017          <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>
12018        </button>
12019      </div>
12020    </div>
12021  </div>
12022
12023  <div class="page">
12024    {watched_dirs_html}
12025    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12026      <div class="scan-overlay-card">
12027        <div class="scan-spinner"></div>
12028        <div class="scan-overlay-text">Scanning folder…</div>
12029        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12030      </div>
12031    </div>
12032    <style>
12033    .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);}}
12034    .scan-overlay.active{{display:flex;}}
12035    .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;}}
12036    .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;}}
12037    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12038    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12039    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12040    </style>
12041    <div class="summary-strip" id="trend-stats"></div>
12042    <div class="panel">
12043      <div class="trend-header">
12044        <div class="trend-title-block">
12045          <h1>Trend Reports</h1>
12046          <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>
12047          <span class="chart-hint-inline">
12048            <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>
12049            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
12050          </span>
12051        </div>
12052        <div class="chart-actions">
12053          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12054            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12055            Retention Policy
12056          </button>
12057          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12058            <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>
12059            Clean up old runs
12060          </button>
12061          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12062            <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>
12063            Export Excel
12064          </button>
12065          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12066            <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>
12067            Export PNG
12068          </button>
12069          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12070            <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>
12071            Export PDF
12072          </button>
12073        </div>
12074      </div>
12075
12076      <div class="controls-centered">
12077        <label>Project Root:
12078          <select class="chart-select" id="root-sel">
12079            <option value="">All projects</option>
12080          </select>
12081        </label>
12082        <label>Y Metric:
12083          <select class="chart-select" id="y-sel">
12084            <option value="code_lines">Code Lines</option>
12085            <option value="comment_lines">Comment Lines</option>
12086            <option value="blank_lines">Blank Lines</option>
12087            <option value="physical_lines">Physical Lines</option>
12088            <option value="files_analyzed">Files Analyzed</option>
12089          </select>
12090        </label>
12091        <label>X Axis:
12092          <select class="chart-select" id="x-sel">
12093            <option value="time">By Time</option>
12094            <option value="commit" selected>By Commit</option>
12095            <option value="release">By Release</option>
12096            <option value="tag">Tagged Commits</option>
12097          </select>
12098        </label>
12099        <label id="submodule-label" style="display:none;">Submodule:
12100          <select class="chart-select" id="sub-sel">
12101            <option value="">All (project total)</option>
12102          </select>
12103        </label>
12104        <label>Chart Size:
12105          <select class="chart-select" id="scale-sel">
12106            <option value="0.75">Compact</option>
12107            <option value="1.2" selected>Normal</option>
12108            <option value="1.38">Large</option>
12109          </select>
12110        </label>
12111        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
12112      </div>
12113
12114      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12115      <div id="data-table-wrap" style="overflow-x:auto;"></div>
12116    </div>
12117  </div>
12118
12119  <script nonce="{nonce}">
12120    (function() {{
12121      // Theme persistence
12122      var b = document.body;
12123      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12124      var tgl = document.getElementById('theme-toggle');
12125      if (tgl) tgl.addEventListener('click', function() {{
12126        var d = b.classList.toggle('dark-theme');
12127        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12128      }});
12129
12130      // Watermark randomizer
12131      (function() {{
12132        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12133        if (!wms.length) return;
12134        var placed = [];
12135        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;}}
12136        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];}}
12137        var half=Math.floor(wms.length/2);
12138        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;}});
12139      }})();
12140
12141      // Code particles
12142      (function() {{
12143        var container = document.getElementById('code-particles');
12144        if (!container) return;
12145        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'];
12146        for (var i = 0; i < 38; i++) {{
12147          (function(idx) {{
12148            var el = document.createElement('span');
12149            el.className = 'code-particle';
12150            el.textContent = snippets[idx % snippets.length];
12151            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12152            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12153            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12154            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';
12155            container.appendChild(el);
12156          }})(i);
12157        }}
12158      }})();
12159
12160      // Watched folder picker
12161      (function(){{
12162        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');}};
12163        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);
12164      }})();
12165      (function() {{
12166        var btn = document.getElementById('add-watched-btn');
12167        if (!btn) return;
12168        btn.addEventListener('click', function() {{
12169          fetch('/pick-directory?kind=reports')
12170            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12171            .then(function(data) {{
12172              if (!data.cancelled && data.selected_path) {{
12173                var form = document.createElement('form');
12174                form.method = 'POST';
12175                form.action = '/watched-dirs/add';
12176                var ri = document.createElement('input');
12177                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12178                var fi = document.createElement('input');
12179                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12180                form.appendChild(ri); form.appendChild(fi);
12181                document.body.appendChild(form);
12182                if (window.__scanOverlay) window.__scanOverlay();
12183                form.submit();
12184              }}
12185            }})
12186            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12187        }});
12188      }})();
12189
12190      // Settings / color-scheme modal
12191      (function() {{
12192        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'}}];
12193        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);}});}}
12194        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12195        var btn=document.getElementById('settings-btn');if(!btn)return;
12196        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12197        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>';
12198        document.body.appendChild(m);
12199        var g=document.getElementById('scheme-grid');
12200        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);}});
12201        var cl=document.getElementById('settings-close');
12202        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);}});}})();
12203        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');}});
12204        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12205        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12206      }})();
12207    }})();
12208
12209    var ROOTS = {roots_json};
12210    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12211    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12212    var allData = [];
12213
12214    // Populate root selector
12215    var rootSel = document.getElementById('root-sel');
12216    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12217
12218    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();}}
12219    function fmtFull(n){{return Number(n).toLocaleString();}}
12220    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
12221
12222    // Tooltip
12223    var tt = document.createElement('div');
12224    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);';
12225    document.body.appendChild(tt);
12226    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12227    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';}}
12228    function hideTT(){{tt.style.display='none';}}
12229    window.addEventListener('blur',function(){{hideTT();}});
12230    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12231
12232    function statExact(compact, full){{
12233      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12234    }}
12235    function statVal(n){{
12236      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12237    }}
12238
12239    function updateStats(data){{
12240      var statsEl=document.getElementById('trend-stats');
12241      if(!statsEl)return;
12242      if(!data||!data.length){{statsEl.innerHTML='';return;}}
12243      var yKey=document.getElementById('y-sel').value;
12244      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12245      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12246      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12247      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12248      var absDelta=Math.abs(delta);
12249      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12250      var deltaExact=statExact(deltaCompact,deltaFull);
12251      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12252      statsEl.innerHTML=
12253        '<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>'+
12254        '<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>'+
12255        '<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>'+
12256        '<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>';
12257    }}
12258
12259    var subSel = document.getElementById('sub-sel');
12260    var subLabel = document.getElementById('submodule-label');
12261
12262    function populateSubmodules(root){{
12263      if(!subSel||!subLabel)return;
12264      while(subSel.options.length>1)subSel.remove(1);
12265      subSel.value='';
12266      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12267      fetch(url)
12268        .then(function(r){{return r.json();}})
12269        .then(function(subs){{
12270          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12271          subs.forEach(function(s){{
12272            var o=document.createElement('option');
12273            o.value=s.name;
12274            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12275            subSel.appendChild(o);
12276          }});
12277          subLabel.style.display='';
12278        }})
12279        .catch(function(){{subLabel.style.display='none';}});
12280    }}
12281
12282    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12283
12284    function loadAndRender(){{
12285      var root = rootSel.value;
12286      var sub = subSel ? subSel.value : '';
12287      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12288      document.getElementById('data-table-wrap').innerHTML='';
12289      var url = '/api/metrics/history?limit=100'
12290        + (root ? '&root='+encodeURIComponent(root) : '')
12291        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
12292      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12293        allData = data;
12294        render(data);
12295        updateStats(data);
12296      }}).catch(function(){{
12297        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>';
12298      }});
12299    }}
12300
12301    function render(data){{
12302      var yKey = document.getElementById('y-sel').value;
12303      var xMode = document.getElementById('x-sel').value;
12304
12305      // Filter for tag/release mode
12306      var pts = data;
12307      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12308
12309      // Sort oldest-first for the line chart
12310      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12311
12312      var wrap = document.getElementById('chart-wrap');
12313      if(!pts.length){{
12314        var emptyMsg = (xMode === 'tag')
12315          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12316          : 'No scan data found for the selected filters.';
12317        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12318        renderTable([]);
12319        return;
12320      }}
12321
12322      var scaleEl=document.getElementById('scale-sel');
12323      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12324      renderTrendInto(wrap, pts, yKey, xMode, sc);
12325      renderTable(pts, yKey);
12326    }}
12327
12328    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12329    // Shared by the inline chart and the Full View modal so both render identically.
12330    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12331      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12332      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12333      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12334      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;
12335      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12336
12337      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12338
12339      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">';
12340      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>';
12341
12342      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12343
12344      // Grid + Y axis ticks
12345      for(var ti=0;ti<=5;ti++){{
12346        var gy=PT+CH-Math.round(ti/5*CH);
12347        var gv=Math.round(ti/5*maxY);
12348        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12349        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12350      }}
12351
12352      // X axis labels (every N-th point to avoid crowding)
12353      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12354      pts.forEach(function(d,i){{
12355        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12356        if(i%labelEvery===0||i===pts.length-1){{
12357          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)));
12358          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>';
12359        }}
12360      }});
12361
12362      // Axis label
12363      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12364      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>';
12365      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>';
12366
12367      // Area fill + line path
12368      var pathD='';
12369      pts.forEach(function(d,i){{
12370        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12371        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12372        pathD+=(i===0?'M':'L')+x+','+y;
12373      }});
12374      if(pts.length>1){{
12375        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12376        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12377      }}
12378      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12379
12380      // Data points (clickable) + permanent value labels
12381      var showLabels = pts.length <= 40;
12382      var labelEveryN = pts.length > 20 ? 2 : 1;
12383      pts.forEach(function(d,i){{
12384        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12385        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12386        var hasTags=d.tags&&d.tags.length>0;
12387        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12388        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12389        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+'"/>';
12390        if(showLabels && i%labelEveryN===0){{
12391          var lx=x, ly=y-r-5;
12392          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>';
12393        }}
12394      }});
12395
12396      svg+='</svg>';
12397      wrap.innerHTML=svg;
12398
12399      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12400      function lineYAt(mx){{
12401        var n=pts.length;
12402        if(n===0)return PT+CH;
12403        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12404        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12405        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12406        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12407        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12408        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12409        return y0+t*(y1-y0);
12410      }}
12411
12412      // SVG-level mousemove: show the value tooltip only when the pointer is over the
12413      // gradient fill (inside the chart and at/below the line) — never in the empty
12414      // space above the line. Cursor follows the same rule.
12415      (function(){{
12416        var svgEl=wrap.querySelector('svg');
12417        if(!svgEl)return;
12418        svgEl.addEventListener('mousemove',function(e){{
12419          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12420          var rect=svgEl.getBoundingClientRect();
12421          var scaleX=W/Math.max(rect.width,1);
12422          var scaleY=H/Math.max(rect.height,1);
12423          var mouseX=(e.clientX-rect.left)*scaleX;
12424          var mouseY=(e.clientY-rect.top)*scaleY;
12425          var ly=lineYAt(mouseX);
12426          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12427          svgEl.style.cursor='pointer';
12428          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12429          var d=pts[idx];
12430          var val=Number(d[yKey]);
12431          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12432          showTT(e,
12433            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12434            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12435            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12436          );
12437        }});
12438        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12439      }})();
12440
12441      // Attach point tooltips
12442      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12443        c.addEventListener('mouseover',function(e){{
12444          var d=pts[parseInt(this.dataset.idx)];
12445          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(''):'';
12446          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>':'';
12447          showTT(e,
12448            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12449            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12450            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12451            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12452          );
12453          this.setAttribute('r','8');
12454        }});
12455        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12456        c.addEventListener('mousemove',moveTT);
12457        c.addEventListener('click',function(){{
12458          var d=pts[parseInt(this.dataset.idx)];
12459          if(d.html_url) window.open(d.html_url,'_blank');
12460        }});
12461      }});
12462    }}
12463
12464    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12465    var shProjFilter='', shBranchFilter='';
12466
12467    function fmtPST(isoStr){{
12468      if(!isoStr)return'';
12469      var d=new Date(isoStr);
12470      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12471      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);}}
12472      function p(n){{return n<10?'0'+n:String(n);}}
12473      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++;}}}}
12474      var yr=d.getUTCFullYear();
12475      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12476      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12477      var isDST=d>=dstStart&&d<dstEnd;
12478      var off=isDST?-7*3600*1000:-8*3600*1000;
12479      var lbl=isDST?'PDT':'PST';
12480      var loc=new Date(d.getTime()+off);
12481      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12482    }}
12483
12484    function getShRows(){{
12485      var proj=shProjFilter.toLowerCase().trim();
12486      var branch=shBranchFilter;
12487      return shData.filter(function(d){{
12488        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12489        if(branch&&(d.branch||'')!==branch)return false;
12490        return true;
12491      }});
12492    }}
12493
12494    function renderShPage(){{
12495      var filtered=getShRows();
12496      if(shSortCol){{
12497        filtered.sort(function(a,b){{
12498          var va,vb;
12499          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12500          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12501          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12502          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12503          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12504          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12505        }});
12506      }}
12507      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12508      shPage=Math.min(shPage,totalPages);
12509      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12510      var visible=filtered.slice(start,end);
12511      var tbody=document.getElementById('sh-tbody');
12512      if(!tbody)return;
12513      tbody.innerHTML=visible.map(function(d){{
12514        var tsHtml=esc(fmtPST(d.timestamp));
12515        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>';
12516        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>';
12517        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12518        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12519        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12520        var reportCell='';
12521        if(d.html_url){{
12522          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12523          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>';}}
12524          reportCell+='</div>';
12525        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12526        if(d.submodule_links&&d.submodule_links.length){{
12527          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12528          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12529          reportCell+='</div></details>';
12530        }}
12531        return '<tr>'
12532          +'<td>'+tsHtml+'</td>'
12533          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12534          +'<td>'+runIdHtml+'</td>'
12535          +'<td>'+commitHtml+'</td>'
12536          +'<td>'+branchHtml+'</td>'
12537          +'<td>'+tags+'</td>'
12538          +'<td class="num">'+metricHtml+'</td>'
12539          +'<td class="report-cell">'+reportCell+'</td>'
12540          +'</tr>';
12541      }}).join('');
12542      var pgRange=document.getElementById('sh-pg-range');
12543      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12544      var pgInfo=document.getElementById('sh-pg-info');
12545      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12546      var pgBtns=document.getElementById('sh-pg-btns');
12547      if(pgBtns){{
12548        pgBtns.innerHTML='';
12549        function mkPgBtn(lbl,pg,active,disabled){{
12550          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12551          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12552          return b;
12553        }}
12554        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12555        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12556        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12557        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12558      }}
12559    }}
12560
12561    function wireTableBehavior(){{
12562      var pf=document.getElementById('sh-proj-filter');
12563      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12564      var bf=document.getElementById('sh-branch-filter');
12565      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12566      var rb=document.getElementById('sh-reset-btn');
12567      if(rb)rb.addEventListener('click',function(){{
12568        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12569        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12570        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12571        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');}});
12572        renderShPage();
12573      }});
12574      var pps=document.getElementById('sh-per-page');
12575      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12576      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12577      ths.forEach(function(th){{
12578        th.addEventListener('click',function(e){{
12579          if(e.target.classList.contains('col-resize-handle'))return;
12580          var col=th.dataset.col;
12581          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12582          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12583          th.classList.add('sort-'+shSortOrder);
12584          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12585          shPage=1;renderShPage();
12586        }});
12587      }});
12588      var table=document.getElementById('scan-history-table');
12589      if(!table)return;
12590      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12591      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12592      allThs.forEach(function(th,i){{
12593        var handle=th.querySelector('.col-resize-handle');
12594        if(!handle||!cols[i])return;
12595        var startX,startW;
12596        handle.addEventListener('mousedown',function(e){{
12597          e.stopPropagation();e.preventDefault();
12598          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12599          handle.classList.add('dragging');
12600          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12601          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12602          document.addEventListener('mousemove',onMove);
12603          document.addEventListener('mouseup',onUp);
12604        }});
12605      }});
12606    }}
12607
12608    function renderTable(pts, yKey){{
12609      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12610      var wrap=document.getElementById('data-table-wrap');
12611      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12612      var yLabel=Y_LABELS[yKey]||yKey||'';
12613      shData=pts.slice().reverse();
12614      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12615      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12616      var branches={{}};
12617      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12618      var branchOpts='<option value="">All branches</option>';
12619      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12620      wrap.innerHTML=
12621        '<div class="chart-section-header">SCAN HISTORY</div>'+
12622        '<div class="filter-row">'+
12623          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12624          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12625          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12626        '</div>'+
12627        '<div class="table-wrap">'+
12628        '<table id="scan-history-table" class="data-table">'+
12629        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12630        '<thead><tr id="sh-thead">'+
12631        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12632        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12633        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12634        '<th>Commit<div class="col-resize-handle"></div></th>'+
12635        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12636        '<th>Tags<div class="col-resize-handle"></div></th>'+
12637        '<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>'+
12638        '<th>Report<div class="col-resize-handle"></div></th>'+
12639        '</tr></thead>'+
12640        '<tbody id="sh-tbody"></tbody>'+
12641        '</table>'+
12642        '</div>'+
12643        '<div class="pagination">'+
12644          '<span class="pagination-info" id="sh-pg-info"></span>'+
12645          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12646          '<div style="display:flex;align-items:center;gap:8px;">'+
12647            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12648            '<select class="filter-select" id="sh-per-page">'+
12649              '<option value="10">10 per page</option>'+
12650              '<option value="25" selected>25 per page</option>'+
12651              '<option value="50">50 per page</option>'+
12652              '<option value="100">100 per page</option>'+
12653            '</select>'+
12654            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12655          '</div>'+
12656        '</div>';
12657      wireTableBehavior();
12658      renderShPage();
12659    }}
12660
12661    function exportXLSX(){{
12662      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12663      var xbtn=document.getElementById('export-xlsx-btn');
12664      var xorig=xbtn?xbtn.innerHTML:'';
12665      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12666      var root=rootSel.value;
12667      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12668      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12669        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12670        buildAndDownloadXLSX(cm);
12671      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12672    }}
12673
12674    function buildAndDownloadXLSX(churnMap){{
12675      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12676      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12677      // (sorted is newest-first), so a given project/commit appears at most once.
12678      var seenPC={{}},dedup=[];
12679      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12680      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12681      var s1R=dedup.map(function(d){{
12682        var c=churnMap[d.run_id]||{{}};
12683        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)];
12684      }});
12685      var pm={{}};
12686      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12687      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'];
12688      var s2R=Object.keys(pm).map(function(p){{
12689        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12690        var lat=sc[sc.length-1],fst=sc[0];
12691        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12692        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);
12693        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];
12694      }});
12695      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12696      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12697      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12698      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12699    }}
12700
12701    function buildXLSX(sheets,chartRows,chartRows2){{
12702      function s2b(s){{return new TextEncoder().encode(s);}}
12703      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12704      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;}}
12705      function crc32(d){{
12706        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;}}}}
12707        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12708      }}
12709      function buildSheet(hdr,rows,drawRid,withCtrl){{
12710        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12711        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12712        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12713        x+='<row r="1">';
12714        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12715        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12716        x+='</row>';
12717        rows.forEach(function(row,ri){{
12718          var rn=ri+2;
12719          x+='<row r="'+rn+'">';
12720          row.forEach(function(cell,ci){{
12721            var addr=col2l(ci+1)+rn;
12722            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12723            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12724          }});
12725          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>";}}
12726          x+='</row>';
12727        }});
12728        x+='</sheetData>';
12729        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12730        return x+'</worksheet>';
12731      }}
12732      function buildChartXML(rows){{
12733        var sn="'Scan History'";
12734        var nr=rows.length,er=nr+1;
12735        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'}}];
12736        var catCol='C',catIdx=2;
12737        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12738        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">';
12739        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12740        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>';
12741        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12742        sd.forEach(function(s,i){{
12743          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12744          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>';
12745          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12746          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>';
12747          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12748          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12749          x+='</c:strCache></c:strRef></c:cat>';
12750          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+'"/>';
12751          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12752          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12753        }});
12754        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12755        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>';
12756        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>';
12757        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12758        return x;
12759      }}
12760      function buildChartXML2(rows){{
12761        var sn="'By Project'";
12762        var nr=rows.length,er=nr+1;
12763        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'}}];
12764        var catCol='A',catIdx=0;
12765        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12766        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">';
12767        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12768        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>';
12769        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12770        sd.forEach(function(s,i){{
12771          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12772          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>';
12773          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12774          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>';
12775          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12776          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12777          x+='</c:strCache></c:strRef></c:cat>';
12778          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+'"/>';
12779          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12780          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12781        }});
12782        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12783        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>';
12784        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>';
12785        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12786        return x;
12787      }}
12788      function buildChartXML3(rows){{
12789        var sn="'Scan History'";
12790        var nr=rows.length,er=nr+1;
12791        var catCol='C',catIdx=2;
12792        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12793        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">';
12794        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12795        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12796        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12797        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>";
12798        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12799        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>';
12800        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>';
12801        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12802        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12803        x+='</c:strCache></c:strRef></c:cat>';
12804        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+'"/>';
12805        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12806        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12807        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12808        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>';
12809        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>';
12810        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>';
12811        return x;
12812      }}
12813      function buildFocusSheet(drawRid){{
12814        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12815        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12816        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12817        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12818        x+='<sheetData><row r="1">';
12819        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12820        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12821        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
12822        x+='</row></sheetData>';
12823        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>';
12824        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12825        return x+'</worksheet>';
12826      }}
12827      var hasChart=!!(chartRows&&chartRows.length);
12828      var nr=hasChart?chartRows.length:0;
12829      var hasChart2=!!(chartRows2&&chartRows2.length);
12830      var nr2=hasChart2?chartRows2.length:0;
12831      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>';
12832      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"/>';
12833      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12834      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"/>';}}
12835      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"/>';}}
12836      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12837      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>';
12838      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12839      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"/>';}});
12840      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12841      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>';
12842      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12843      wbx+='</sheets></workbook>';
12844      var files=[
12845        {{name:'[Content_Types].xml',data:s2b(ct)}},
12846        {{name:'_rels/.rels',data:s2b(dotrels)}},
12847        {{name:'xl/workbook.xml',data:s2b(wbx)}},
12848        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12849        {{name:'xl/styles.xml',data:s2b(styl)}}
12850      ];
12851      // Chart embedded directly in Scan History (sheet1); By Project is plain
12852      sheets.forEach(function(s,i){{
12853        var sx;
12854        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12855        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12856        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12857      }});
12858      if(hasChart){{
12859        var fromRow=nr+4,toRow=nr+34;
12860        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>')}});
12861        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12862        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">';
12863        drx+='<xdr:twoCellAnchor editAs="twoCell">';
12864        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>';
12865        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>';
12866        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12867        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12868        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12869        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12870        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12871        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12872        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>')}});
12873        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12874        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>')}});
12875        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12876        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">';
12877        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12878        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>';
12879        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>';
12880        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12881        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12882        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12883        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12884        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12885        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12886        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>')}});
12887        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12888      }}
12889      if(hasChart2){{
12890        var fromRow2=nr2+4,toRow2=nr2+36;
12891        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>')}});
12892        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12893        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">';
12894        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12895        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>';
12896        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>';
12897        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12898        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12899        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12900        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12901        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12902        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12903        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>')}});
12904        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12905      }}
12906      var parts=[],offsets=[],total=0;
12907      files.forEach(function(f){{
12908        offsets.push(total);
12909        var nb=s2b(f.name),crc=crc32(f.data);
12910        var h=new DataView(new ArrayBuffer(30+nb.length));
12911        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12912        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12913        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12914        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12915        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12916        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12917        total+=30+nb.length+f.data.length;
12918      }});
12919      var cdStart=total;
12920      files.forEach(function(f,fi){{
12921        var nb=s2b(f.name),crc=crc32(f.data);
12922        var cd=new DataView(new ArrayBuffer(46+nb.length));
12923        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12924        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12925        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12926        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12927        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12928        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12929        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12930      }});
12931      var cdSz=total-cdStart;
12932      var eocd=new DataView(new ArrayBuffer(22));
12933      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12934      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12935      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12936      parts.push(new Uint8Array(eocd.buffer));
12937      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12938      var out=new Uint8Array(sz);var off=0;
12939      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12940      return out.buffer;
12941    }}
12942
12943    function trendTitleParts(){{
12944      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12945      var subSelEl=document.getElementById('sub-sel');
12946      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12947      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12948      var proj=(document.getElementById('root-sel').value)||'All projects';
12949      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12950      var cnt=(allData&&allData.length)||0;
12951      var now=new Date();
12952      function p2(n){{return(n<10?'0':'')+n;}}
12953      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12954      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
12955    }}
12956
12957    function exportPNG(){{
12958      var svgEl=document.querySelector('#chart-wrap svg');
12959      if(!svgEl){{alert('No chart to export yet.');return;}}
12960      var svgStr=new XMLSerializer().serializeToString(svgEl);
12961      var vb=svgEl.viewBox.baseVal,scale=2;
12962      var headerH=84,footerH=36;
12963      var lw=(vb.width||900),lh=(vb.height||380);
12964      var w=lw*scale,h=(lh+headerH+footerH)*scale;
12965      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12966      var url=URL.createObjectURL(blob);
12967      var img=new Image();
12968      var tp=trendTitleParts();
12969      img.onload=function(){{
12970        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12971        var ctx=canvas.getContext('2d');
12972        var cs=getComputedStyle(document.body);
12973        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12974        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12975        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12976        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12977        ctx.scale(scale,scale);
12978        ctx.textBaseline='alphabetic';ctx.textAlign='left';
12979        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12980        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12981        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12982        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;
12983        ctx.drawImage(img,0,headerH);
12984        var fy=headerH+lh;
12985        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;
12986        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12987        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);
12988        ctx.textAlign='left';
12989        URL.revokeObjectURL(url);
12990        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12991      }};
12992      img.src=url;
12993    }}
12994
12995    function exportPDF(){{
12996      var svgEl=document.querySelector('#chart-wrap svg');
12997      if(!svgEl){{alert('No chart to export yet.');return;}}
12998      var tp=trendTitleParts();
12999      var svgStr=new XMLSerializer().serializeToString(svgEl);
13000      var statsEl=document.getElementById('trend-stats');
13001      var statsHtml=statsEl?statsEl.innerHTML:'';
13002      var yK=document.getElementById('y-sel').value;
13003      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13004      var yL=yLabels[yK]||yK;
13005      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13006      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>';
13007      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>';}});
13008      tableHtml+='</tbody></table>';
13009      var css='<style>'
13010        +'*{{box-sizing:border-box;}}'
13011        +'html,body{{margin:0;padding:0;}}'
13012        // Masthead/footer flow in document order — a position:fixed header repeats
13013        // on every printed page in Chromium and hides the rows beneath it on pages
13014        // 2+. The trend table's <thead> repeats per page natively instead.
13015        +'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;}}'
13016        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13017        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13018        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13019        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13020        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13021        +'.rep-body{{padding:22px 34px 0;}}'
13022        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13023        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13024        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13025        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13026        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13027        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13028        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13029        +'.stat-chip-tip{{display:none!important;}}'
13030        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13031        +'.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;}}'
13032        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13033        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13034        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13035        +'.rep-chart svg{{max-width:100%;height:auto;}}'
13036        +'.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;}}'
13037        +'.filter-row{{display:none!important;}}'
13038        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13039        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13040        +'th{{background:#f0e9e0;font-weight:800;}}'
13041        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13042        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13043        +'.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;}}'
13044        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13045        +'</style>';
13046      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13047        +'<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>'
13048        +'<div class="rep-body">'
13049        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13050        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13051        +'<div class="summary-strip">'+statsHtml+'</div>'
13052        +'<div class="rep-chart">'+svgStr+'</div>'
13053        +tableHtml
13054        +'</div>'
13055        +'<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>'
13056        +'</body></html>';
13057      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13058    }}
13059
13060    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13061      var el=document.getElementById(id);
13062      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13063    }});
13064    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13065    // tracks the container like the responsive Chart.js charts do.
13066    var _rsT=null;
13067    window.addEventListener('resize',function(){{
13068      if(_rsT)clearTimeout(_rsT);
13069      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13070    }});
13071    rootSel.addEventListener('change',function(){{
13072      populateSubmodules(rootSel.value);
13073      loadAndRender();
13074    }});
13075    if(subSel)subSel.addEventListener('change',loadAndRender);
13076
13077    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13078    (function(){{
13079      var fvBtn=document.getElementById('tr-chart-fv-btn');
13080      if(!fvBtn)return;
13081      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13082      fvBtn.addEventListener('click',function(){{
13083        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13084        var yKey=document.getElementById('y-sel').value;
13085        var xMode=document.getElementById('x-sel').value;
13086        var pts=allData;
13087        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13088        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13089        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13090        var tp=trendTitleParts();
13091        var ov=document.createElement('div');
13092        ov.className='tr-chart-full-modal';
13093        ov.innerHTML='<div class="tr-chart-full-inner">'
13094          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13095          +'<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>'
13096          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13097          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13098          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13099        document.body.appendChild(ov);
13100        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13101        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13102        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13103        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13104        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13105      }});
13106    }})();
13107
13108    var xlsxBtn=document.getElementById('export-xlsx-btn');
13109    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13110    var pngBtn=document.getElementById('export-png-btn');
13111    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13112    var pdfBtn=document.getElementById('export-pdf-btn');
13113    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13114
13115    // ── Clean-up modal ───────────────────────────────────────────────────────
13116    (function(){{
13117      var triggerBtn=document.getElementById('cleanup-runs-btn');
13118      if(!triggerBtn)return;
13119      var modal=document.createElement('div');
13120      modal.className='tr-modal-backdrop';
13121      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13122        +'<div class="tr-modal-head">'
13123        +'<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>'
13124        +'<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>'
13125        +'</div>'
13126        +'<div class="tr-modal-body">'
13127        +'<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>'
13128        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13129        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13130        +'<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;">'
13131        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13132        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13133        +'</div>'
13134        +'<div class="tr-modal-foot">'
13135        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13136        +'<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>'
13137        +'</div></div>';
13138      document.body.appendChild(modal);
13139      triggerBtn.addEventListener('click',function(){{
13140        document.getElementById('cleanup-status').style.display='none';
13141        modal.style.display='flex';
13142      }});
13143      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13144      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13145      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13146        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13147        var confirmBtn=this;
13148        confirmBtn.disabled=true;
13149        var status=document.getElementById('cleanup-status');
13150        status.style.display='block';
13151        status.style.background='#dbeafe';status.style.color='#1e40af';
13152        status.textContent='Deleting\u2026';
13153        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13154        .then(function(resp){{
13155          return resp.json().then(function(d){{
13156            if(resp.ok){{
13157              status.style.background='#dcfce7';status.style.color='#166534';
13158              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13159              setTimeout(function(){{window.location.reload();}},1500);
13160            }}else{{
13161              status.style.background='#fee2e2';status.style.color='#991b1b';
13162              status.textContent='Error: '+(d.error||'Unexpected error');
13163              confirmBtn.disabled=false;
13164            }}
13165          }});
13166        }})
13167        .catch(function(e){{
13168          status.style.background='#fee2e2';status.style.color='#991b1b';
13169          status.textContent='Network error: '+String(e);
13170          confirmBtn.disabled=false;
13171        }});
13172      }});
13173    }})();
13174
13175    // ── Retention policy panel ────────────────────────────────────────────────
13176    (function(){{
13177      var triggerBtn=document.getElementById('retention-policy-btn');
13178      if(!triggerBtn)return;
13179      var modal=document.createElement('div');
13180      modal.className='tr-modal-backdrop';
13181      modal.style.zIndex='9001';
13182      modal.innerHTML=''
13183        +'<div class="tr-modal" style="max-width:640px;">'
13184        +'<div class="tr-modal-head">'
13185        +'<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>'
13186        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13187        +'</div>'
13188        +'<div class="tr-modal-body">'
13189        +'<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>'
13190        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13191        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13192        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13193        +'</div>'
13194        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13195        +'<div>'
13196        +'<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>'
13197        +'<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;">'
13198        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13199        +'</div>'
13200        +'<div>'
13201        +'<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>'
13202        +'<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;">'
13203        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13204        +'</div>'
13205        +'</div>'
13206        +'<div style="margin-bottom:20px;">'
13207        +'<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>'
13208        +'<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;">'
13209        +'<option value="1">Every hour</option>'
13210        +'<option value="6">Every 6 hours</option>'
13211        +'<option value="12">Every 12 hours</option>'
13212        +'<option value="24" selected>Every 24 hours</option>'
13213        +'<option value="48">Every 2 days</option>'
13214        +'<option value="72">Every 3 days</option>'
13215        +'<option value="168">Every week</option>'
13216        +'</select>'
13217        +'</div>'
13218        +'<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>'
13219        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13220        +'</div>'
13221        +'<div class="tr-modal-foot">'
13222        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13223        +'<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>'
13224        +'<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>'
13225        +'</div>'
13226        +'</div>';
13227      document.body.appendChild(modal);
13228
13229      function rpShowStatus(msg,ok){{
13230        var s=document.getElementById('rp-status');
13231        s.style.display='block';
13232        s.style.background=ok?'#dcfce7':'#fee2e2';
13233        s.style.color=ok?'#166534':'#991b1b';
13234        s.textContent=msg;
13235      }}
13236      function fmtAgo(iso){{
13237        if(!iso)return'Never';
13238        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13239        if(diff<60)return diff+'s ago';
13240        if(diff<3600)return Math.floor(diff/60)+'m ago';
13241        if(diff<86400)return Math.floor(diff/3600)+'h ago';
13242        return Math.floor(diff/86400)+'d ago';
13243      }}
13244      function loadPolicy(){{
13245        fetch('/api/cleanup-policy')
13246          .then(function(r){{return r.json();}})
13247          .then(function(d){{
13248            var p=d.policy;
13249            document.getElementById('rp-enabled').checked=p?p.enabled:false;
13250            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13251            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13252            var sel=document.getElementById('rp-interval');
13253            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;}}}}}}
13254            var lr=document.getElementById('rp-last-run');
13255            if(d.last_run_at){{
13256              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'):'');
13257            }}else{{
13258              lr.textContent='Auto-cleanup has not run yet.';
13259            }}
13260          }})
13261          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13262      }}
13263
13264      triggerBtn.addEventListener('click',function(){{
13265        document.getElementById('rp-status').style.display='none';
13266        loadPolicy();
13267        modal.style.display='flex';
13268      }});
13269      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13270      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13271
13272      document.getElementById('rp-save-btn').addEventListener('click',function(){{
13273        var enabled=document.getElementById('rp-enabled').checked;
13274        var ageVal=document.getElementById('rp-max-age').value.trim();
13275        var countVal=document.getElementById('rp-max-count').value.trim();
13276        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13277        if(enabled&&!ageVal&&!countVal){{
13278          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13279          return;
13280        }}
13281        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13282        var saveBtn=document.getElementById('rp-save-btn');
13283        saveBtn.disabled=true;
13284        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13285          .then(function(r){{
13286            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13287            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13288          }})
13289          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13290          .finally(function(){{saveBtn.disabled=false;}});
13291      }});
13292
13293      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13294        var btn=this;
13295        var orig=btn.innerHTML;
13296        btn.disabled=true;
13297        btn.textContent='Running\u2026';
13298        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13299          .then(function(r){{return r.json();}})
13300          .then(function(d){{
13301            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13302            loadPolicy();
13303          }})
13304          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13305          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13306      }});
13307    }})();
13308
13309    populateSubmodules(rootSel.value);
13310    loadAndRender();
13311
13312    (function randomizeWatermarks() {{
13313      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13314      if (!wms.length) return;
13315      var placed = [];
13316      function tooClose(top, left) {{
13317        for (var i = 0; i < placed.length; i++) {{
13318          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13319          if (dt < 16 && dl < 12) return true;
13320        }}
13321        return false;
13322      }}
13323      function pick(leftBand) {{
13324        for (var attempt = 0; attempt < 50; attempt++) {{
13325          var top = Math.random() * 88 + 2;
13326          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13327          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13328        }}
13329        var top = Math.random() * 88 + 2;
13330        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13331        placed.push([top, left]); return [top, left];
13332      }}
13333      var half = Math.floor(wms.length / 2);
13334      wms.forEach(function (img, i) {{
13335        var pos = pick(i < half);
13336        var size = Math.floor(Math.random() * 100 + 120);
13337        var rot = (Math.random() * 360).toFixed(1);
13338        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13339        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;
13340      }});
13341    }})();
13342    (function spawnCodeParticles() {{
13343      var container = document.getElementById('code-particles');
13344      if (!container) return;
13345      var snippets = [
13346        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13347        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13348        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13349        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13350        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13351      ];
13352      var count = 38;
13353      for (var i = 0; i < count; i++) {{
13354        (function(idx) {{
13355          var el = document.createElement('span');
13356          el.className = 'code-particle';
13357          el.textContent = snippets[idx % snippets.length];
13358          var left = Math.random() * 94 + 2;
13359          var top = Math.random() * 88 + 6;
13360          var dur = (Math.random() * 10 + 9).toFixed(1);
13361          var delay = (Math.random() * 18).toFixed(1);
13362          var rot = (Math.random() * 26 - 13).toFixed(1);
13363          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13364          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13365          container.appendChild(el);
13366        }})(i);
13367      }}
13368    }})();
13369  </script>
13370  <footer class="site-footer">
13371    local code analysis - metrics, history and reports
13372    &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>
13373    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13374    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13375    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13376    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13377  </footer>
13378  <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>
13379  {toast_assets}
13380</body>
13381</html>"##,
13382    );
13383
13384    Html(html).into_response()
13385}
13386
13387fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13388    use std::collections::HashMap;
13389    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13390        return vec![];
13391    }
13392    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13393    for rec in per_file_records {
13394        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13395            let e = totals.entry(lang.display_name().to_string()).or_default();
13396            e.0 += u64::from(cov.lines_found);
13397            e.1 += u64::from(cov.lines_hit);
13398        }
13399    }
13400    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13401    let mut pairs: Vec<(String, f64)> = totals
13402        .into_iter()
13403        .filter(|(_, (found, _))| *found > 0)
13404        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13405        .collect();
13406    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13407    pairs
13408        .iter()
13409        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13410        .collect()
13411}
13412
13413fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13414    let mut high = 0u64;
13415    let mut mid = 0u64;
13416    let mut low = 0u64;
13417    for rec in per_file_records {
13418        if let Some(cov) = &rec.coverage {
13419            if cov.lines_found == 0 {
13420                continue;
13421            }
13422            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13423            if pct >= 80.0 {
13424                high += 1;
13425            } else if pct >= 50.0 {
13426                mid += 1;
13427            } else {
13428                low += 1;
13429            }
13430        }
13431    }
13432    (high, mid, low)
13433}
13434
13435fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13436    let mut arr: Vec<serde_json::Value> = per_file_records
13437        .iter()
13438        .filter_map(|rec| {
13439            rec.coverage.as_ref().map(|cov| {
13440                let line_pct = if cov.lines_found > 0 {
13441                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13442                        / 10.0
13443                } else {
13444                    0.0
13445                };
13446                let fn_pct = if cov.functions_found > 0 {
13447                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13448                        .round()
13449                        / 10.0
13450                } else {
13451                    -1.0
13452                };
13453                serde_json::json!({
13454                    "rel": rec.relative_path,
13455                    "lang": rec.language.map_or("?", |l| l.display_name()),
13456                    "line_pct": line_pct,
13457                    "fn_pct": fn_pct,
13458                    "lhit": cov.lines_hit,
13459                    "lfound": cov.lines_found,
13460                    "fhit": cov.functions_hit,
13461                    "ffound": cov.functions_found,
13462                })
13463            })
13464        })
13465        .collect();
13466    arr.sort_by(|a, b| {
13467        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13468        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13469        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13470    });
13471    arr
13472}
13473
13474#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13475fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13476    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13477        .totals_by_language
13478        .iter()
13479        .filter(|l| l.test_count > 0)
13480        .collect();
13481    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13482    let lang_tests: Vec<serde_json::Value> = langs
13483        .iter()
13484        .map(|l| {
13485            let d = if l.code_lines > 0 {
13486                l.test_count as f64 / l.code_lines as f64 * 1000.0
13487            } else {
13488                0.0
13489            };
13490            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13491                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13492                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13493        })
13494        .collect();
13495    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13496    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13497    let t = &run.summary_totals;
13498    let total_tests = t.test_count;
13499    let density = if t.code_lines > 0 {
13500        total_tests as f64 / t.code_lines as f64 * 1000.0
13501    } else {
13502        0.0
13503    };
13504    let most_tested = langs.first().map_or_else(
13505        || "\u{2014}".to_string(),
13506        |l| l.language.display_name().to_string(),
13507    );
13508    let test_files: u64 = run
13509        .per_file_records
13510        .iter()
13511        .filter(|f| f.raw_line_categories.test_count > 0)
13512        .count() as u64;
13513    let cov_line = if t.coverage_lines_found > 0 {
13514        format!(
13515            "{:.1}",
13516            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13517        )
13518    } else {
13519        "0".to_string()
13520    };
13521    let cov_fn = if t.coverage_functions_found > 0 {
13522        format!(
13523            "{:.1}",
13524            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13525        )
13526    } else {
13527        "0".to_string()
13528    };
13529    let cov_branch = if t.coverage_branches_found > 0 {
13530        format!(
13531            "{:.1}",
13532            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13533        )
13534    } else {
13535        "0".to_string()
13536    };
13537    let has_cov = !cov_arr.is_empty();
13538    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13539    serde_json::json!({
13540        "totals": {
13541            "test_count": total_tests,
13542            "assertions": t.test_assertion_count,
13543            "suites": t.test_suite_count,
13544            "test_files": test_files,
13545            "total_files": t.files_analyzed,
13546            "density_str": format!("{density:.1}"),
13547            "most_tested": most_tested,
13548            "langs_with_tests": langs.len(),
13549            "cov_line": cov_line,
13550            "cov_fn": cov_fn,
13551            "cov_branch": cov_branch,
13552        },
13553        "lang_tests": lang_tests,
13554        "cov": cov_arr,
13555        "cov_tiers": {"high": high, "mid": mid, "low": low},
13556        "file_cov": file_cov_arr,
13557        "has_coverage": has_cov,
13558        "submodules": {},
13559    })
13560}
13561
13562#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13563fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13564    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13565        .language_summaries
13566        .iter()
13567        .filter(|l| l.test_count > 0)
13568        .collect();
13569    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13570    let lang_tests: Vec<serde_json::Value> = langs
13571        .iter()
13572        .map(|l| {
13573            let d = if l.code_lines > 0 {
13574                l.test_count as f64 / l.code_lines as f64 * 1000.0
13575            } else {
13576                0.0
13577            };
13578            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13579                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13580                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13581        })
13582        .collect();
13583    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13584    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13585    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13586    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13587    let density = if sub.code_lines > 0 {
13588        total_tests as f64 / sub.code_lines as f64 * 1000.0
13589    } else {
13590        0.0
13591    };
13592    let most_tested = langs.first().map_or_else(
13593        || "\u{2014}".to_string(),
13594        |l| l.language.display_name().to_string(),
13595    );
13596    serde_json::json!({
13597        "totals": {
13598            "test_count": total_tests,
13599            "assertions": total_assertions,
13600            "suites": total_suites,
13601            "test_files": test_files_approx,
13602            "total_files": sub.files_analyzed,
13603            "density_str": format!("{density:.1}"),
13604            "most_tested": most_tested,
13605            "langs_with_tests": langs.len(),
13606            "cov_line": "0",
13607            "cov_fn": "0",
13608            "cov_branch": "0",
13609        },
13610        "lang_tests": lang_tests,
13611        "cov": [],
13612        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13613        "has_coverage": false,
13614    })
13615}
13616
13617fn compute_cov_json_str(run: &AnalysisRun) -> String {
13618    use std::collections::HashMap;
13619    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13620    for rec in &run.per_file_records {
13621        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13622            let e = totals.entry(lang.display_name().to_string()).or_default();
13623            e.0 += u64::from(cov.lines_found);
13624            e.1 += u64::from(cov.lines_hit);
13625        }
13626    }
13627    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13628    let mut pairs: Vec<(String, f64)> = totals
13629        .into_iter()
13630        .filter(|(_, (found, _))| *found > 0)
13631        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13632        .collect();
13633    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13634    let parts: Vec<String> = pairs
13635        .iter()
13636        .map(|(lang, pct)| {
13637            let name = lang.replace('"', "\\\"");
13638            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13639        })
13640        .collect();
13641    format!("[{}]", parts.join(","))
13642}
13643
13644fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13645    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13646    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13647}
13648
13649fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13650    let mut entry = build_test_scope_entry(run);
13651    if !run.submodule_summaries.is_empty() {
13652        let subs: serde_json::Map<String, serde_json::Value> = run
13653            .submodule_summaries
13654            .iter()
13655            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13656            .collect();
13657        entry["submodules"] = serde_json::Value::Object(subs);
13658    }
13659    entry
13660}
13661
13662fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13663    let name = l.language.display_name().replace('"', "\\\"");
13664    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13665    let density = if l.code_lines > 0 {
13666        l.test_count as f64 / l.code_lines as f64 * 1000.0
13667    } else {
13668        0.0
13669    };
13670    format!(
13671        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13672        name = name,
13673        t = l.test_count,
13674        a = l.test_assertion_count,
13675        s = l.test_suite_count,
13676        c = l.code_lines,
13677        d = density,
13678        f = l.files,
13679    )
13680}
13681
13682fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13683    let Some(r) = run else {
13684        return "[]".to_string();
13685    };
13686    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13687        .totals_by_language
13688        .iter()
13689        .filter(|l| l.test_count > 0)
13690        .collect();
13691    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13692    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13693    format!("[{}]", parts.join(","))
13694}
13695
13696/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13697async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13698    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13699    scope_map.insert(
13700        "__all__".to_string(),
13701        latest_run.map_or_else(
13702            || {
13703                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13704                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13705                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13706                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13707                    "has_coverage":false,"submodules":{}})
13708            },
13709            build_test_scope_entry,
13710        ),
13711    );
13712    let all_roots: Vec<String> = {
13713        let reg = state.registry.lock().await;
13714        let mut seen = std::collections::BTreeSet::new();
13715        reg.entries
13716            .iter()
13717            .flat_map(|e| e.input_roots.iter().cloned())
13718            .filter(|r| seen.insert(r.clone()))
13719            .collect()
13720    };
13721    for root in &all_roots {
13722        let json_path = {
13723            let reg = state.registry.lock().await;
13724            reg.entries
13725                .iter()
13726                .find(|e| e.input_roots.iter().any(|r| r == root))
13727                .and_then(|e| e.json_path.clone())
13728        };
13729        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13730            let json_str = tokio::fs::read_to_string(&p).await.ok();
13731            json_str
13732                .as_deref()
13733                .and_then(|s| serde_json::from_str(s).ok())
13734        } else {
13735            None
13736        };
13737        if let Some(ref run) = run_for_root {
13738            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13739        }
13740    }
13741    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13742}
13743
13744// GET /test-metrics
13745#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13746#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13747async fn test_metrics_handler(
13748    State(state): State<AppState>,
13749    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13750) -> Response {
13751    auto_scan_watched_dirs(&state).await;
13752    let watched_dirs_list: Vec<String> = {
13753        let wd = state.watched_dirs.lock().await;
13754        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13755    };
13756    let latest_run: Option<AnalysisRun> = {
13757        let json_path = {
13758            let reg = state.registry.lock().await;
13759            reg.entries.first().and_then(|e| e.json_path.clone())
13760        };
13761        if let Some(p) = json_path {
13762            let json_str = tokio::fs::read_to_string(&p).await.ok();
13763            json_str
13764                .as_deref()
13765                .and_then(|s| serde_json::from_str(s).ok())
13766        } else {
13767            None
13768        }
13769    };
13770
13771    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
13772    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13773
13774    // Build coverage chart JSON (per-language avg line coverage %).
13775    let cov_json: String = latest_run
13776        .as_ref()
13777        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13778        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13779
13780    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
13781    let _cov_tier_json: String = latest_run
13782        .as_ref()
13783        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13784        .map_or_else(
13785            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13786            compute_cov_tier_json_str,
13787        );
13788
13789    let total_tests: u64 = latest_run
13790        .as_ref()
13791        .map_or(0, |r| r.summary_totals.test_count);
13792    let total_assertions: u64 = latest_run
13793        .as_ref()
13794        .map_or(0, |r| r.summary_totals.test_assertion_count);
13795    let total_suites: u64 = latest_run
13796        .as_ref()
13797        .map_or(0, |r| r.summary_totals.test_suite_count);
13798    let total_code: u64 = latest_run
13799        .as_ref()
13800        .map_or(0, |r| r.summary_totals.code_lines);
13801    let workspace_density: f64 = if total_code > 0 {
13802        total_tests as f64 / total_code as f64 * 1000.0
13803    } else {
13804        0.0
13805    };
13806    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13807        r.totals_by_language
13808            .iter()
13809            .filter(|l| l.test_count > 0)
13810            .count()
13811    });
13812    let most_tested: String = latest_run
13813        .as_ref()
13814        .and_then(|r| {
13815            r.totals_by_language
13816                .iter()
13817                .filter(|l| l.test_count > 0)
13818                .max_by_key(|l| l.test_count)
13819        })
13820        .map_or_else(
13821            || "\u{2014}".to_string(),
13822            |l| l.language.display_name().to_string(),
13823        );
13824    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13825        r.per_file_records
13826            .iter()
13827            .filter(|f| f.raw_line_categories.test_count > 0)
13828            .count() as u64
13829    });
13830    let total_files_analyzed: u64 = latest_run
13831        .as_ref()
13832        .map_or(0, |r| r.summary_totals.files_analyzed);
13833    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13834
13835    // Aggregated coverage percentages from summary_totals
13836    let cov_line_pct_str: String = latest_run
13837        .as_ref()
13838        .filter(|r| r.summary_totals.coverage_lines_found > 0)
13839        .map_or_else(
13840            || "0".to_string(),
13841            |r| {
13842                format!(
13843                    "{:.1}",
13844                    r.summary_totals.coverage_lines_hit as f64
13845                        / r.summary_totals.coverage_lines_found as f64
13846                        * 100.0
13847                )
13848            },
13849        );
13850    let cov_fn_pct_str: String = latest_run
13851        .as_ref()
13852        .filter(|r| r.summary_totals.coverage_functions_found > 0)
13853        .map_or_else(
13854            || "0".to_string(),
13855            |r| {
13856                format!(
13857                    "{:.1}",
13858                    r.summary_totals.coverage_functions_hit as f64
13859                        / r.summary_totals.coverage_functions_found as f64
13860                        * 100.0
13861                )
13862            },
13863        );
13864    let cov_branch_pct_str: String = latest_run
13865        .as_ref()
13866        .filter(|r| r.summary_totals.coverage_branches_found > 0)
13867        .map_or_else(
13868            || "0".to_string(),
13869            |r| {
13870                format!(
13871                    "{:.1}",
13872                    r.summary_totals.coverage_branches_hit as f64
13873                        / r.summary_totals.coverage_branches_found as f64
13874                        * 100.0
13875                )
13876            },
13877        );
13878
13879    let cov_no_data_notice = if has_coverage {
13880        String::new()
13881    } else {
13882        String::from(
13883            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13884<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>
13885<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13886  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13887  <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>
13888  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13889  <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>
13890  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13891  <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>
13892  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13893  <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>
13894  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13895  <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>
13896</div>
13897<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13898</div>"#,
13899        )
13900    };
13901
13902    let workspace_density_str = format!("{workspace_density:.1}");
13903    let nonce = &csp_nonce;
13904    let toast_assets = sloc_toast_assets(nonce);
13905    let version = env!("CARGO_PKG_VERSION");
13906
13907    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
13908    // of interactive controls — folder watching is managed by the host administrator.
13909    let watched_dirs_html: String = if state.server_mode {
13910        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()
13911    } else {
13912        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13913            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13914                .to_string()
13915        } else {
13916            watched_dirs_list
13917                .iter()
13918                .fold(String::new(), |mut s, d| {
13919                    use std::fmt::Write as _;
13920                    let escaped =
13921                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
13922                    write!(
13923                        s,
13924                        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>"#
13925                    ).expect("write to String is infallible");
13926                    s
13927                })
13928        };
13929        format!(
13930            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>"#
13931        )
13932    };
13933
13934    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
13935    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13936
13937    let html = format!(
13938        r#"<!doctype html>
13939<html lang="en">
13940<head>
13941  <meta charset="utf-8" />
13942  <meta name="viewport" content="width=device-width, initial-scale=1" />
13943  <title>OxideSLOC | Test Metrics</title>
13944  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13945  <style nonce="{nonce}">
13946    :root {{
13947      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13948      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13949      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13950      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13951      --info-bg:#eef3ff; --info-text:#4467d8;
13952    }}
13953    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13954    *{{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;}}
13955    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13956    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13957    .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;}}
13958    @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));}}}}
13959    .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);}}
13960    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13961    .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));}}
13962    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13963    .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;}}
13964    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13965    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13966    @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; }} }}
13967    .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;}}
13968    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13969    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13970    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13971    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13972    .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;}}
13973    .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;}}
13974    .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;}}
13975    .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;}}
13976    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13977    .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);}}
13978    .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;}}
13979    .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;}}
13980    .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;}}
13981    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13982    .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;}}
13983    .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);}}
13984    .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;}}
13985    .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;}}
13986    .tz-select:focus{{border-color:var(--oxide);}}
13987    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13988    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13989    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13990    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13991    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13992    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13993    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13994    .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);}}
13995    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13996    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13997    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13998    .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;}}
13999    .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;}}
14000    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14001    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14002    .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);}}
14003    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14004    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14005    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14006    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14007    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14008    .chart-canvas-wrap{{position:relative;height:280px;}}
14009    .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;}}
14010    .chart-no-data svg{{opacity:0.35;}}
14011    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14012    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14013    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14014    .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;}}
14015    .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;}}
14016    .data-table tr:last-child td{{border-bottom:none;}}
14017    .data-table tbody tr:hover td{{background:var(--surface-2);}}
14018    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14019    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14020    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14021    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14022    .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;}}
14023    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14024    .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);}}
14025    .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14026    .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14027    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14028    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14029    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14030    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14031    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14032    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14033    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14034    .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;}}
14035    .chart-select:focus{{border-color:var(--accent);}}
14036    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14037    .trend-canvas-wrap{{position:relative;height:260px;}}
14038    .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;}}
14039    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14040    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14041    .site-footer a{{color:var(--muted);}}
14042    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14043    .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;}}
14044    .btn:hover{{background:var(--surface-2);}}
14045    .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;}}
14046    .export-btn:hover{{background:var(--line);}}
14047    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14048    /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14049    .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14050    .scope-export{{margin-left:auto;}}
14051    body.pdf-mode .export-group{{display:none!important;}}
14052    @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14053    .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;}}
14054    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14055    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14056    .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;}}
14057    .scope-sel:focus{{border-color:var(--accent);}}
14058    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14059    .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;}}
14060    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14061    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14062    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14063    .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;}}
14064    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14065    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14066    .watched-chip-rm:hover{{color:var(--oxide);}}
14067    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14068    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14069    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14070    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14071    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14072    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14073    .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;}}
14074    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14075    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14076    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14077    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14078    .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;}}
14079    .cov-file-search:focus{{border-color:var(--accent);}}
14080    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14081    .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;}}
14082    body.dark-theme .cov-file-search{{background:var(--surface);}}
14083    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14084    .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;}}
14085    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14086    .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;}}
14087    .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);}}
14088    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14089    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14090    .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;}}
14091    .chart-modal-close:hover{{opacity:.7;}}
14092    body.dark-theme .chart-modal{{background:var(--surface);}}
14093  </style>
14094</head>
14095<body>
14096  <div class="background-watermarks" aria-hidden="true">
14097    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14098    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14099    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14100    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14101    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14102    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14103  </div>
14104  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14105  <div class="top-nav">
14106    <div class="top-nav-inner">
14107      <a class="brand" href="/">
14108        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14109        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14110      </a>
14111      <div class="nav-right">
14112        <a class="nav-pill" href="/">Home</a>
14113        <div class="nav-dropdown">
14114          <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>
14115          <div class="nav-dropdown-menu">
14116            <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>
14117          </div>
14118        </div>
14119        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14120        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14121        <div class="nav-dropdown">
14122          <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>
14123          <div class="nav-dropdown-menu">
14124            <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>
14125          </div>
14126        </div>
14127        <div class="server-status-wrap" id="server-status-wrap">
14128          <div class="nav-pill server-online-pill" id="server-status-pill">
14129            <span class="status-dot" id="status-dot"></span>
14130            <span id="server-status-label">Server</span>
14131            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14132          </div>
14133          <div class="server-status-tip">
14134            OxideSLOC is running — accessible on your network.
14135            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14136          </div>
14137        </div>
14138        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14139          <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>
14140        </button>
14141        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14142          <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>
14143          <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>
14144        </button>
14145      </div>
14146    </div>
14147  </div>
14148
14149  <div class="page">
14150    {watched_dirs_html}
14151    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14152      <div class="scan-overlay-card">
14153        <div class="scan-spinner"></div>
14154        <div class="scan-overlay-text">Scanning folder…</div>
14155        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14156      </div>
14157    </div>
14158    <style>
14159    .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);}}
14160    .scan-overlay.active{{display:flex;}}
14161    .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;}}
14162    .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;}}
14163    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14164    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14165    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14166    </style>
14167    <div class="scope-bar">
14168      <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>
14169      <span class="scope-label">Scope</span>
14170      <div class="scope-sel-wrap">
14171        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14172        <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);">
14173          <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>
14174          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14175        </div>
14176      </div>
14177      <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14178      <div class="export-group scope-export" id="tm-export-group">
14179        <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)">
14180          <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>
14181          Export Excel
14182        </button>
14183        <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14184          <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>
14185          Export PNG
14186        </button>
14187        <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14188          <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>
14189          Export PDF
14190        </button>
14191      </div>
14192    </div>
14193    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14194      <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>
14195      <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>
14196      <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>
14197      <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>
14198    </div>
14199    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14200      <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>
14201      <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>
14202      <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>
14203      <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>
14204    </div>
14205
14206    <div class="panel" id="viz-panel">
14207      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14208
14209      <div class="chart-box" style="margin-bottom:18px;">
14210        <div class="chart-box-header">
14211          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14212          <div style="display:flex;gap:8px;align-items:center;">
14213            <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>
14214            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14215          </div>
14216        </div>
14217        <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>
14218        <div class="trend-controls-bar">
14219          <label>Y Metric:
14220            <select class="chart-select" id="tm-trend-y">
14221              <option value="test_count" selected>Test Definitions</option>
14222              <option value="code_lines">Code Lines</option>
14223            </select>
14224          </label>
14225          <label>X Axis:
14226            <select class="chart-select" id="tm-trend-x">
14227              <option value="commit" selected>By Commit</option>
14228              <option value="time">By Time</option>
14229            </select>
14230          </label>
14231          <label id="tm-sub-label" style="display:none;">Submodule:
14232            <select class="chart-select" id="tm-trend-sub">
14233              <option value="">All (project total)</option>
14234            </select>
14235          </label>
14236          <label>Chart Size:
14237            <select class="chart-select" id="tm-trend-size">
14238              <option value="200">Compact</option>
14239              <option value="260" selected>Normal</option>
14240              <option value="360">Large</option>
14241            </select>
14242          </label>
14243        </div>
14244        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14245        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14246      </div>
14247
14248      <div class="chart-row">
14249        <div class="chart-box">
14250          <div class="chart-box-header">
14251            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14252            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14253          </div>
14254          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14255          <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>
14256        </div>
14257        <div class="chart-box">
14258          <div class="chart-box-header">
14259            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14260            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14261          </div>
14262          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14263          <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>
14264        </div>
14265      </div>
14266
14267      <div class="chart-row">
14268        <div class="chart-box">
14269          <div class="chart-box-header">
14270            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14271            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14272          </div>
14273          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14274          <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>
14275        </div>
14276        <div class="chart-box" id="suites-chart-box">
14277          <div class="chart-box-header">
14278            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14279            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14280          </div>
14281          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14282          <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>
14283        </div>
14284      </div>
14285
14286      <div class="chart-row">
14287        <div class="chart-box">
14288          <div class="chart-box-title">Test Files Breakdown</div>
14289          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14290          <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>
14291        </div>
14292        <div class="chart-box">
14293          <div class="chart-box-title">Test Composition</div>
14294          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14295          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14296          <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>
14297        </div>
14298      </div>
14299    </div>
14300
14301    <div class="panel">
14302      <h1>Test Metrics</h1>
14303      <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>
14304
14305      <div class="section-header">Language Breakdown</div>
14306      {cov_no_data_notice}
14307      <div style="overflow-x:auto;">
14308        <table class="data-table" id="lang-table">
14309          <thead><tr>
14310            <th>Language</th>
14311            <th class="num">Test Fns</th>
14312            <th class="num">Assertions</th>
14313            <th class="num">Suites</th>
14314            <th class="num">Code Lines</th>
14315            <th class="num">Files</th>
14316            <th class="num">Density / 1K</th>
14317            <th>Relative Density</th>
14318          </tr></thead>
14319          <tbody id="lang-tbody"></tbody>
14320        </table>
14321      </div>
14322    </div>
14323
14324    <div class="panel" id="cov-panel" style="display:none;">
14325      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14326      <div class="cov-gauge-row" id="cov-gauges">
14327        <div class="cov-gauge-card">
14328          <div class="cov-gauge-label">Line Coverage</div>
14329          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14330          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14331          <div class="cov-gauge-sub">Lines hit / instrumented</div>
14332          <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>
14333        </div>
14334        <div class="cov-gauge-card">
14335          <div class="cov-gauge-label">Function Coverage</div>
14336          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14337          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14338          <div class="cov-gauge-sub">Functions hit / found</div>
14339          <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>
14340        </div>
14341        <div class="cov-gauge-card">
14342          <div class="cov-gauge-label">Branch Coverage</div>
14343          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14344          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14345          <div class="cov-gauge-sub">Branches hit / found</div>
14346          <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>
14347        </div>
14348      </div>
14349      <div class="chart-row">
14350        <div class="chart-box">
14351          <div class="chart-box-title">Line Coverage % by Language</div>
14352          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14353        </div>
14354        <div class="chart-box">
14355          <div class="chart-box-title">Coverage Tier Distribution</div>
14356          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14357        </div>
14358      </div>
14359
14360      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14361      <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>
14362      <div class="cov-file-toolbar">
14363        <div class="cov-filter-tabs" id="cov-filter-tabs">
14364          <button class="cov-tab active" data-tier="all">All</button>
14365          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14366          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
14367          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
14368          <button class="cov-tab" data-tier="high">High (≥80%)</button>
14369        </div>
14370        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14371      </div>
14372      <div style="overflow-x:auto;">
14373        <table class="data-table" id="cov-file-table">
14374          <thead><tr>
14375            <th>File</th>
14376            <th>Lang</th>
14377            <th class="num">Line %</th>
14378            <th class="num">Lines Hit / Found</th>
14379            <th class="num">Fn %</th>
14380            <th class="num">Fns Hit / Found</th>
14381          </tr></thead>
14382          <tbody id="cov-file-tbody"></tbody>
14383        </table>
14384      </div>
14385      <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>
14386      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14387    </div>
14388
14389  </div>
14390
14391  <footer class="site-footer">
14392    local code analysis - metrics, history and reports
14393    &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>
14394    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14395    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14396    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14397    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14398  </footer>
14399
14400  <script nonce="{nonce}">
14401  (function() {{
14402    // Theme
14403    var b = document.body;
14404    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14405    var tgl = document.getElementById('theme-toggle');
14406    if (tgl) tgl.addEventListener('click', function() {{
14407      var d = b.classList.toggle('dark-theme');
14408      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14409    }});
14410
14411    // Watermarks
14412    (function() {{
14413      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14414      if (!wms.length) return;
14415      var placed = [];
14416      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;}}
14417      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];}}
14418      var half=Math.floor(wms.length/2);
14419      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;}});
14420    }})();
14421
14422    // Code particles
14423    (function() {{
14424      var container = document.getElementById('code-particles');
14425      if (!container) return;
14426      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14427      for (var i = 0; i < 36; i++) {{
14428        (function(idx) {{
14429          var el = document.createElement('span');
14430          el.className = 'code-particle';
14431          el.textContent = snippets[idx % snippets.length];
14432          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14433          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14434          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14435          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';
14436          container.appendChild(el);
14437        }})(i);
14438      }}
14439    }})();
14440
14441    // Settings modal
14442    (function() {{
14443      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'}}];
14444      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);}});}}
14445      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14446      var btn=document.getElementById('settings-btn');if(!btn)return;
14447      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14448      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>';
14449      document.body.appendChild(m);
14450      var g=document.getElementById('scheme-grid');
14451      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);}});
14452      var cl=document.getElementById('settings-close');
14453      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');}});
14454      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14455      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14456    }})();
14457
14458    // Watched folder picker
14459    (function(){{
14460      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');}};
14461      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);
14462    }})();
14463    (function() {{
14464      var btn = document.getElementById('add-watched-btn');
14465      if (!btn) return;
14466      btn.addEventListener('click', function() {{
14467        fetch('/pick-directory?kind=reports')
14468          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14469          .then(function(data) {{
14470            if (!data.cancelled && data.selected_path) {{
14471              var form = document.createElement('form');
14472              form.method = 'POST';
14473              form.action = '/watched-dirs/add';
14474              var ri = document.createElement('input');
14475              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14476              var fi = document.createElement('input');
14477              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14478              form.appendChild(ri); form.appendChild(fi);
14479              document.body.appendChild(form);
14480              if (window.__scanOverlay) window.__scanOverlay();
14481              form.submit();
14482            }}
14483          }})
14484          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14485      }});
14486    }})();
14487  }})();
14488  </script>
14489
14490  <script src="/static/chart.js" nonce="{nonce}"></script>
14491  <script nonce="{nonce}">
14492  (function() {{
14493    var SCOPE_DATA = {scope_data_json};
14494    var currentRoot = '__all__';
14495    var currentSub  = '';
14496    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14497    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14498    var ALL_CHARTS = [];
14499    var currentLangTests = [];
14500    var currentTrendPts = [];
14501
14502    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();}}
14503    function fmtFull(n){{return Number(n).toLocaleString();}}
14504    function isDark(){{return document.body.classList.contains('dark-theme');}}
14505    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14506    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14507    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14508
14509    function makeDlPlugin(fmtFn, anchor) {{
14510      return {{
14511        afterDatasetsDraw: function(chart) {{
14512          var ctx = chart.ctx;
14513          var tc = txtClr();
14514          chart.data.datasets.forEach(function(ds, di) {{
14515            var meta = chart.getDatasetMeta(di);
14516            meta.data.forEach(function(el, idx) {{
14517              var label = fmtFn(ds.data[idx], di, idx);
14518              if (label == null || label === '') return;
14519              ctx.save();
14520              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14521              ctx.fillStyle = tc;
14522              if (anchor === 'top') {{
14523                ctx.textAlign = 'center';
14524                ctx.textBaseline = 'bottom';
14525                ctx.fillText(String(label), el.x, el.y - 5);
14526              }} else {{
14527                ctx.textAlign = 'left';
14528                ctx.textBaseline = 'middle';
14529                ctx.fillText(String(label), el.x + 5, el.y);
14530              }}
14531              ctx.restore();
14532            }});
14533          }});
14534        }}
14535      }};
14536    }}
14537
14538    // Cursor: pointer over chart data, default over empty chart area.
14539    function chartCursor(e, els) {{
14540      var t = e.native && e.native.target;
14541      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14542    }}
14543    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14544
14545    // ── Global bar hover emphasis ──────────────────────────────────────────────
14546    // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14547    // *other* bars does nothing when there is only one). Give every bar chart a
14548    // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14549    // animates via the fast active transition. Applied globally through a plugin so
14550    // it covers all current and future bar charts on the page.
14551    function tmLighten(c, amt) {{
14552      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14553        var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14554        r = Math.round(r + (255 - r) * amt);
14555        g = Math.round(g + (255 - g) * amt);
14556        b = Math.round(b + (255 - b) * amt);
14557        return 'rgb(' + r + ',' + g + ',' + b + ')';
14558      }}
14559      return c;
14560    }}
14561    var tmBarHoverEmphasis = {{
14562      id: 'tmBarHoverEmphasis',
14563      beforeInit: function(chart) {{
14564        if (!chart.config || chart.config.type !== 'bar') return;
14565        (chart.data.datasets || []).forEach(function(ds) {{
14566          var bg = ds.backgroundColor;
14567          if (ds.hoverBackgroundColor == null) {{
14568            ds.hoverBackgroundColor = Array.isArray(bg)
14569              ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14570              : tmLighten(bg, 0.24);
14571          }}
14572          if (ds.hoverBorderColor == null) {{
14573            ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14574          }}
14575          if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14576        }});
14577      }}
14578    }};
14579    Chart.register(tmBarHoverEmphasis);
14580    // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14581    try {{
14582      Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14583      Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14584      Chart.defaults.transitions.active.animation.duration = 260;
14585    }} catch (e) {{}}
14586
14587    // Plugin: draws % labels inside each doughnut slice.
14588    var donutPctPlugin = {{
14589      afterDatasetsDraw: function(chart) {{
14590        var ctx = chart.ctx;
14591        chart.data.datasets.forEach(function(ds, di) {{
14592          var meta = chart.getDatasetMeta(di);
14593          if (meta.hidden) return;
14594          var total = 0;
14595          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14596          if (!total) return;
14597          meta.data.forEach(function(arc, i) {{
14598            if (arc.hidden) return;
14599            var val = ds.data[i] || 0;
14600            var pct = val / total * 100;
14601            if (pct < 3) return;
14602            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14603            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14604            var tx = arc.x + midR * Math.cos(midAngle);
14605            var ty = arc.y + midR * Math.sin(midAngle);
14606            ctx.save();
14607            ctx.textAlign = 'center';
14608            ctx.textBaseline = 'middle';
14609            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14610            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14611            ctx.shadowBlur = 3;
14612            ctx.fillStyle = '#fff';
14613            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14614            ctx.restore();
14615          }});
14616        }});
14617      }}
14618    }};
14619
14620    function makeTmOverlay(title, subtitle, h) {{
14621      var overlay = document.createElement('div');
14622      overlay.className = 'chart-modal-overlay';
14623      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14624      var ch = Math.min(h || 560, maxH);
14625      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14626      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>';
14627      document.body.appendChild(overlay);
14628      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14629      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14630      return document.getElementById('tm-modal-canvas');
14631    }}
14632
14633    function getDataset() {{
14634      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14635      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14636      return r;
14637    }}
14638    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14639
14640    function showNoData(id, show) {{
14641      var el = document.getElementById(id);
14642      if (!el) return;
14643      var wrap = el.previousElementSibling;
14644      el.style.display = show ? '' : 'none';
14645      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14646    }}
14647
14648    // Shared hover treatment for every single-series bar/doughnut chart on this page:
14649    // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14650    // treatment used by the language charts on the scan results page.
14651    function tmFadeColor(c) {{
14652      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14653      return c;
14654    }}
14655    function tmApplyFade(chart, activeIdx) {{
14656      var ds = chart.data.datasets[0];
14657      if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14658      if (activeIdx == null) {{
14659        ds.backgroundColor = ds._baseBg.slice();
14660      }} else {{
14661        ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14662          return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14663        }});
14664      }}
14665    }}
14666    function tmFadeHover(e, active, chart) {{
14667      var t = e.native && e.native.target;
14668      if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14669      var idx = active.length ? active[0].index : null;
14670      if (chart._fadeIdx === idx) return;
14671      chart._fadeIdx = idx;
14672      tmApplyFade(chart, idx);
14673      // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14674      // transition (doughnuts keep their own hoverOffset motion regardless).
14675      chart.update('active');
14676    }}
14677    // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14678    function tmDoughnutLegendHover(e, item, leg) {{
14679      var ch = leg.chart;
14680      var t = e.native && e.native.target;
14681      if (t) t.style.cursor = 'pointer';
14682      ch._fadeIdx = item.index;
14683      ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14684      ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14685      tmApplyFade(ch, item.index);
14686      ch.update();
14687    }}
14688    function tmDoughnutLegendLeave(e, item, leg) {{
14689      var ch = leg.chart;
14690      var t = e.native && e.native.target;
14691      if (t) t.style.cursor = 'default';
14692      ch._fadeIdx = null;
14693      ch.setActiveElements([]);
14694      ch.tooltip.setActiveElements([], {{}});
14695      tmApplyFade(ch, null);
14696      ch.update('none');
14697    }}
14698
14699    function renderTestCharts(D) {{
14700      currentLangTests = D || [];
14701      testsChart = destroyChart(testsChart);
14702      densityChart = destroyChart(densityChart);
14703      if (!D || !D.length) {{
14704        showNoData('no-data-tests', true);
14705        showNoData('no-data-density', true);
14706        return;
14707      }}
14708      showNoData('no-data-tests', false);
14709      showNoData('no-data-density', false);
14710      var top15 = D.slice(0, 15);
14711      var canvas1 = document.getElementById('canvas-tests');
14712      if (canvas1) {{
14713        testsChart = new Chart(canvas1, {{
14714          type: 'bar',
14715          data: {{
14716            labels: top15.map(function(d){{ return d.lang; }}),
14717            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14718          }},
14719          options: {{
14720            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14721            layout: {{ padding: {{ right: 64 }} }},
14722            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14723            scales: {{
14724              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14725              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14726            }}
14727          }},
14728          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14729        }});
14730        ALL_CHARTS.push(testsChart);
14731      }}
14732      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14733      var canvas2 = document.getElementById('canvas-density');
14734      if (canvas2) {{
14735        densityChart = new Chart(canvas2, {{
14736          type: 'bar',
14737          data: {{
14738            labels: topD.map(function(d){{ return d.lang; }}),
14739            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 }}]
14740          }},
14741          options: {{
14742            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14743            layout: {{ padding: {{ right: 64 }} }},
14744            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14745            scales: {{
14746              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14747              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14748            }}
14749          }},
14750          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14751        }});
14752        ALL_CHARTS.push(densityChart);
14753      }}
14754    }}
14755
14756    function renderAssertionsChart(D) {{
14757      assertionsChart = destroyChart(assertionsChart);
14758      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14759      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14760      var canvas = document.getElementById('canvas-assertions');
14761      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14762      showNoData('no-data-assertions', false);
14763      assertionsChart = new Chart(canvas, {{
14764        type: 'bar',
14765        data: {{
14766          labels: top15.map(function(d){{ return d.lang; }}),
14767          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14768        }},
14769        options: {{
14770          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14771          layout: {{ padding: {{ right: 64 }} }},
14772          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14773          scales: {{
14774            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14775            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14776          }}
14777        }},
14778        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14779      }});
14780      ALL_CHARTS.push(assertionsChart);
14781    }}
14782
14783    function renderSuitesChart(D) {{
14784      suitesChart = destroyChart(suitesChart);
14785      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14786      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14787      var canvas = document.getElementById('canvas-suites');
14788      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14789      showNoData('no-data-suites', false);
14790      suitesChart = new Chart(canvas, {{
14791        type: 'bar',
14792        data: {{
14793          labels: top15.map(function(d){{ return d.lang; }}),
14794          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 }}]
14795        }},
14796        options: {{
14797          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14798          layout: {{ padding: {{ right: 64 }} }},
14799          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14800          scales: {{
14801            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14802            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14803          }}
14804        }},
14805        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14806      }});
14807      ALL_CHARTS.push(suitesChart);
14808    }}
14809
14810    function renderFilesChart(totals) {{
14811      filesChart = destroyChart(filesChart);
14812      var canvas = document.getElementById('canvas-files');
14813      if (!canvas) return;
14814      var testF = totals.test_files || 0;
14815      var totalF = totals.total_files || 0;
14816      var nonTest = Math.max(0, totalF - testF);
14817      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14818      showNoData('no-data-files', false);
14819      var dark = isDark();
14820      filesChart = new Chart(canvas, {{
14821        type: 'doughnut',
14822        data: {{
14823          labels: ['Test Files', 'Non-Test Files'],
14824          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14825        }},
14826        options: {{
14827          responsive: true, maintainAspectRatio: false, cutout: '62%',
14828          onHover: tmFadeHover,
14829          plugins: {{
14830            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14831              generateLabels: function(chart) {{
14832                var ds = chart.data.datasets[0];
14833                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14834                return chart.data.labels.map(function(lbl, i) {{
14835                  var val = ds.data[i] || 0;
14836                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14837                  return {{
14838                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14839                    fillStyle: ds.backgroundColor[i],
14840                    strokeStyle: ds.borderColor,
14841                    lineWidth: ds.borderWidth,
14842                    hidden: false,
14843                    index: i,
14844                    datasetIndex: 0
14845                  }};
14846                }});
14847              }}
14848            }},
14849              onHover: tmDoughnutLegendHover,
14850              onLeave: tmDoughnutLegendLeave
14851            }},
14852            tooltip: {{ callbacks: {{ label: function(ctx) {{
14853              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14854              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14855            }} }} }}
14856          }}
14857        }},
14858        plugins: [donutPctPlugin]
14859      }});
14860      ALL_CHARTS.push(filesChart);
14861    }}
14862
14863    function renderCompositionChart(totals) {{
14864      compositionChart = destroyChart(compositionChart);
14865      var canvas = document.getElementById('canvas-composition');
14866      if (!canvas) return;
14867      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14868      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14869      showNoData('no-data-composition', false);
14870      compositionChart = new Chart(canvas, {{
14871        type: 'bar',
14872        data: {{
14873          labels: ['Test Functions', 'Assertions', 'Test Suites'],
14874          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14875        }},
14876        options: {{
14877          responsive: true, maintainAspectRatio: false,
14878          onHover: tmFadeHover,
14879          layout: {{ padding: {{ top: 22 }} }},
14880          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14881          scales: {{
14882            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14883            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14884          }}
14885        }},
14886        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14887      }});
14888      ALL_CHARTS.push(compositionChart);
14889    }}
14890
14891    function renderCovCharts(covD, tiers) {{
14892      covChart = destroyChart(covChart);
14893      tierChart = destroyChart(tierChart);
14894      var covCanvas = document.getElementById('canvas-cov');
14895      if (covCanvas && covD && covD.length) {{
14896        covChart = new Chart(covCanvas, {{
14897          type: 'bar',
14898          data: {{
14899            labels: covD.map(function(d){{ return d.lang; }}),
14900            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 }}]
14901          }},
14902          options: {{
14903            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14904            layout: {{ padding: {{ right: 52 }} }},
14905            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14906            scales: {{
14907              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14908              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14909            }}
14910          }},
14911          plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
14912        }});
14913        ALL_CHARTS.push(covChart);
14914      }}
14915      var tierCanvas = document.getElementById('canvas-cov-tiers');
14916      if (tierCanvas && tiers) {{
14917        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14918        tierChart = new Chart(tierCanvas, {{
14919          type: 'doughnut',
14920          data: {{
14921            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14922            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14923          }},
14924          options: {{
14925            responsive: true, maintainAspectRatio: false, cutout: '62%',
14926            onHover: tmFadeHover,
14927            plugins: {{
14928              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14929                onHover: tmDoughnutLegendHover,
14930                onLeave: tmDoughnutLegendLeave
14931              }},
14932              tooltip: {{ callbacks: {{ label: function(ctx) {{
14933                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14934                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14935              }} }} }}
14936            }}
14937          }},
14938          plugins: [donutPctPlugin]
14939        }});
14940        ALL_CHARTS.push(tierChart);
14941      }}
14942    }}
14943
14944    function buildLangTable(D) {{
14945      var tbody = document.getElementById('lang-tbody');
14946      if (!tbody) return;
14947      if (!D || !D.length) {{
14948        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>';
14949        return;
14950      }}
14951      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14952      tbody.innerHTML = D.map(function(d) {{
14953        var barW = Math.round(d.density / maxDensity * 120);
14954        return '<tr>' +
14955          '<td><strong>' + d.lang + '</strong></td>' +
14956          '<td class="num">' + fmtFull(d.tests) + '</td>' +
14957          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14958          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14959          '<td class="num">' + fmtFull(d.code) + '</td>' +
14960          '<td class="num">' + fmtFull(d.files) + '</td>' +
14961          '<td class="num">' + d.density.toFixed(2) + '</td>' +
14962          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14963          '</tr>';
14964      }}).join('');
14965    }}
14966
14967    var covFileData = [];
14968    var covFileTier = 'all';
14969    var covFileSearch = '';
14970
14971    function pctBadge(pct) {{
14972      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14973      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14974      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14975    }}
14976
14977    function buildCovFileTable() {{
14978      var tbody = document.getElementById('cov-file-tbody');
14979      var empty = document.getElementById('cov-file-empty');
14980      var count = document.getElementById('cov-file-count');
14981      if (!tbody) return;
14982      var srch = covFileSearch.toLowerCase();
14983      var filtered = covFileData.filter(function(f) {{
14984        if (covFileTier === 'zero' && f.line_pct > 0) return false;
14985        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14986        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14987        if (covFileTier === 'high' && f.line_pct < 80) return false;
14988        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14989        return true;
14990      }});
14991      if (!filtered.length) {{
14992        tbody.innerHTML = '';
14993        if (empty) empty.style.display = '';
14994        if (count) count.textContent = '';
14995        return;
14996      }}
14997      if (empty) empty.style.display = 'none';
14998      var shown = Math.min(filtered.length, 500);
14999      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15000      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15001        var fnCol = f.fn_pct < 0
15002          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15003          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15004        return '<tr>' +
15005          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
15006          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15007          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15008          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15009          fnCol +
15010          '</tr>';
15011      }}).join('');
15012    }}
15013
15014    (function() {{
15015      var tabs = document.getElementById('cov-filter-tabs');
15016      if (tabs) {{
15017        tabs.addEventListener('click', function(e) {{
15018          var btn = e.target.closest('.cov-tab');
15019          if (!btn) return;
15020          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15021          btn.classList.add('active');
15022          covFileTier = btn.getAttribute('data-tier');
15023          buildCovFileTable();
15024        }});
15025      }}
15026      var srch = document.getElementById('cov-file-search');
15027      if (srch) {{
15028        srch.addEventListener('input', function() {{
15029          covFileSearch = this.value;
15030          buildCovFileTable();
15031        }});
15032      }}
15033    }})();
15034
15035    function updateCovGauges(t) {{
15036      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15037      var el;
15038      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15039      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15040      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15041      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15042      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15043      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15044    }}
15045
15046    function applyScope() {{
15047      var d = getDataset();
15048      var t = d.totals;
15049      var el;
15050      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15051      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15052      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15053      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15054      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15055      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15056      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15057      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15058      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15059      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15060      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15061      renderTestCharts(d.lang_tests);
15062      renderAssertionsChart(d.lang_tests);
15063      renderSuitesChart(d.lang_tests);
15064      renderFilesChart(t);
15065      renderCompositionChart(t);
15066      buildLangTable(d.lang_tests);
15067      var covPanel = document.getElementById('cov-panel');
15068      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15069      if (d.has_coverage) {{
15070        renderCovCharts(d.cov, d.cov_tiers);
15071        updateCovGauges(t);
15072        covFileData = d.file_cov || [];
15073        covFileTier = 'all';
15074        covFileSearch = '';
15075        var tabs = document.getElementById('cov-filter-tabs');
15076        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15077        var srch = document.getElementById('cov-file-search');
15078        if (srch) srch.value = '';
15079        buildCovFileTable();
15080      }}
15081      loadTrend();
15082    }}
15083
15084    // Populate scope-root-sel from SCOPE_DATA keys
15085    (function() {{
15086      var sel = document.getElementById('scope-root-sel');
15087      if (!sel) return;
15088      Object.keys(SCOPE_DATA).forEach(function(k) {{
15089        if (k === '__all__') return;
15090        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15091      }});
15092    }})();
15093
15094    document.getElementById('scope-root-sel').addEventListener('change', function() {{
15095      currentRoot = this.value;
15096      currentSub = '';
15097      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15098      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15099      var subWrap = document.getElementById('scope-sub-wrap');
15100      var subSel  = document.getElementById('scope-sub-sel');
15101      subSel.innerHTML = '<option value="">Entire project</option>';
15102      if (subNames.length) {{
15103        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15104        subWrap.style.display = 'flex';
15105      }} else {{
15106        subWrap.style.display = 'none';
15107      }}
15108      applyScope();
15109    }});
15110
15111    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15112      currentSub = this.value;
15113      applyScope();
15114    }});
15115
15116    var allTrendData = [];
15117
15118    var TM_Y_META = {{
15119      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15120      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
15121    }};
15122
15123    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15124    function hexRgb(hex) {{
15125      var h = String(hex).replace('#', '');
15126      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15127      var n = parseInt(h, 16);
15128      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15129    }}
15130    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15131    // tint at the top to transparent at the bottom (no flat solid block).
15132    function tmTrendGradient(ctx2, chartArea, color) {{
15133      var rgb = hexRgb(color);
15134      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15135      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
15136      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15137      g.addColorStop(1,   'rgba(' + rgb + ',0)');
15138      return g;
15139    }}
15140
15141    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15142    // so linear interpolation between adjacent points matches the drawn line).
15143    function tmLineYAt(chart, px) {{
15144      var meta = chart.getDatasetMeta(0);
15145      if (!meta || !meta.data || !meta.data.length) return null;
15146      var d = meta.data;
15147      if (px <= d[0].x) return d[0].y;
15148      for (var i = 1; i < d.length; i++) {{
15149        if (px <= d[i].x) {{
15150          var span = d[i].x - d[i - 1].x;
15151          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15152          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15153        }}
15154      }}
15155      return d[d.length - 1].y;
15156    }}
15157
15158    // Plugin: only show the tooltip / finger cursor when the pointer is over the
15159    // gradient fill (inside the plot and at/below the line) — never in the empty
15160    // space above the line. Outside the fill we retype the event as 'mouseout' so
15161    // the core interaction dismisses any active tooltip on its own.
15162    var tmFillGuard = {{
15163      id: 'tmFillGuard',
15164      beforeEvent: function(chart, args) {{
15165        var e = args.event;
15166        if (!e || e.type !== 'mousemove') return;
15167        var ca = chart.chartArea;
15168        if (!ca) return;
15169        var inFill = false;
15170        if (e.x >= ca.left && e.x <= ca.right) {{
15171          var ly = tmLineYAt(chart, e.x);
15172          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15173        }}
15174        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15175        if (!inFill) {{ e.type = 'mouseout'; }}
15176      }}
15177    }};
15178
15179    // Single source of truth for the test-metrics trend chart config so the inline
15180    // chart and the Full View modal render identically (straight segments, gradient
15181    // fill, white-ringed points, gradient-only interactivity).
15182    function buildTmTrendConfig(pts, ctrl, meta) {{
15183      return {{
15184        type: 'line',
15185        data: {{
15186          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15187          datasets: [{{
15188            label: meta.label,
15189            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15190            borderColor: meta.color,
15191            borderWidth: 2.5,
15192            backgroundColor: function(context) {{
15193              var ca = context.chart.chartArea;
15194              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15195              return tmTrendGradient(context.chart.ctx, ca, meta.color);
15196            }},
15197            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15198            pointBorderColor: '#fff',
15199            pointBorderWidth: 2,
15200            pointRadius: 6,
15201            pointHoverRadius: 9,
15202            pointHoverBorderWidth: 2.5,
15203            fill: true, tension: 0
15204          }}]
15205        }},
15206        options: {{
15207          responsive: true, maintainAspectRatio: false,
15208          layout: {{ padding: {{ top: 22 }} }},
15209          interaction: {{ mode: 'index', intersect: false }},
15210          plugins: {{
15211            legend: {{ display: false }},
15212            tooltip: {{
15213              mode: 'index', intersect: false,
15214              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15215            }}
15216          }},
15217          scales: {{
15218            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15219            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15220          }}
15221        }},
15222        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15223      }};
15224    }}
15225
15226    function getTrendControls() {{
15227      var ySel    = document.getElementById('tm-trend-y');
15228      var xSel    = document.getElementById('tm-trend-x');
15229      var sizeSel = document.getElementById('tm-trend-size');
15230      var subSel  = document.getElementById('tm-trend-sub');
15231      return {{
15232        yKey:    ySel    ? ySel.value    : 'test_count',
15233        xMode:   xSel    ? xSel.value    : 'commit',
15234        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
15235        submod:  subSel  ? subSel.value  : ''
15236      }};
15237    }}
15238
15239    function makeTrendLabel(d, xMode) {{
15240      if (xMode === 'commit') {{
15241        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15242      }}
15243      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15244    }}
15245
15246    function buildTrend(data) {{
15247      allTrendData = data || [];
15248      renderTrend();
15249    }}
15250
15251    function renderTrend() {{
15252      var data = allTrendData;
15253      var ctrl = getTrendControls();
15254      var trendCanvas = document.getElementById('canvas-trend');
15255      var trendWrap   = document.getElementById('trend-canvas-wrap');
15256      var trendEmpty  = document.getElementById('trend-empty');
15257
15258      // Apply chart size
15259      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15260
15261      // Filter by submodule if selected (entries from project_label match)
15262      var pts = data.slice().reverse();
15263      if (ctrl.submod) {{
15264        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15265      }}
15266
15267      currentTrendPts = pts;
15268
15269      if (!pts.length) {{
15270        if (trendCanvas) trendCanvas.style.display = 'none';
15271        if (trendEmpty) trendEmpty.style.display = '';
15272        return;
15273      }}
15274      if (trendCanvas) trendCanvas.style.display = '';
15275      if (trendEmpty) trendEmpty.style.display = 'none';
15276
15277      trendChart = destroyChart(trendChart);
15278      if (!trendCanvas) return;
15279
15280      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15281
15282      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15283      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15284      ALL_CHARTS.push(trendChart);
15285
15286      // Populate submodule selector from unique project_labels
15287      var subSel = document.getElementById('tm-trend-sub');
15288      var subLabel = document.getElementById('tm-sub-label');
15289      if (subSel && data.length) {{
15290        var projects = [];
15291        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15292        if (projects.length > 1) {{
15293          var curVal = subSel.value;
15294          subSel.innerHTML = '<option value="">All (project total)</option>';
15295          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15296          if (subLabel) subLabel.style.display = '';
15297        }} else {{
15298          if (subLabel) subLabel.style.display = 'none';
15299        }}
15300      }}
15301    }}
15302
15303    // ── Full View expand buttons ──────────────────────────────────────────────
15304    (function() {{
15305      var btn = document.getElementById('tests-expand-btn');
15306      if (!btn) return;
15307      btn.addEventListener('click', function() {{
15308        var D = currentLangTests;
15309        if (!D || !D.length) return;
15310        var top15 = D.slice(0, 15);
15311        var h = Math.max(320, top15.length * 36 + 80);
15312        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15313        if (!canvas) return;
15314        new Chart(canvas, {{
15315          type: 'bar',
15316          data: {{
15317            labels: top15.map(function(d){{ return d.lang; }}),
15318            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15319          }},
15320          options: {{
15321            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15322            layout: {{ padding: {{ right: 72 }} }},
15323            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15324            scales: {{
15325              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15326              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15327            }}
15328          }},
15329          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15330        }});
15331      }});
15332    }})();
15333
15334    (function() {{
15335      var btn = document.getElementById('density-expand-btn');
15336      if (!btn) return;
15337      btn.addEventListener('click', function() {{
15338        var D = currentLangTests;
15339        if (!D || !D.length) return;
15340        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15341        var h = Math.max(320, topD.length * 36 + 80);
15342        var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15343        if (!canvas) return;
15344        new Chart(canvas, {{
15345          type: 'bar',
15346          data: {{
15347            labels: topD.map(function(d){{ return d.lang; }}),
15348            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 }}]
15349          }},
15350          options: {{
15351            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15352            layout: {{ padding: {{ right: 72 }} }},
15353            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15354            scales: {{
15355              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15356              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15357            }}
15358          }},
15359          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15360        }});
15361      }});
15362    }})();
15363
15364    (function() {{
15365      var btn = document.getElementById('trend-expand-btn');
15366      if (!btn) return;
15367      btn.addEventListener('click', function() {{
15368        var pts = currentTrendPts;
15369        if (!pts || !pts.length) return;
15370        var ctrl = getTrendControls();
15371        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15372        var title = meta.label + ' Trend \u2014 Full View';
15373        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15374        if (!canvas) return;
15375        // Reuse the exact inline-chart config so Full View matches the default view
15376        // (straight segments + gradient-only interactivity), just larger.
15377        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15378      }});
15379    }})();
15380
15381    (function() {{
15382      var btn = document.getElementById('assertions-expand-btn');
15383      if (!btn) return;
15384      btn.addEventListener('click', function() {{
15385        var D = currentLangTests;
15386        if (!D || !D.length) return;
15387        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15388        if (!top15.length) return;
15389        var h = Math.max(320, top15.length * 36 + 80);
15390        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15391        if (!canvas) return;
15392        new Chart(canvas, {{
15393          type: 'bar',
15394          data: {{
15395            labels: top15.map(function(d){{ return d.lang; }}),
15396            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15397          }},
15398          options: {{
15399            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15400            layout: {{ padding: {{ right: 72 }} }},
15401            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15402            scales: {{
15403              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15404              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15405            }}
15406          }},
15407          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15408        }});
15409      }});
15410    }})();
15411
15412    (function() {{
15413      var btn = document.getElementById('suites-expand-btn');
15414      if (!btn) return;
15415      btn.addEventListener('click', function() {{
15416        var D = currentLangTests;
15417        if (!D || !D.length) return;
15418        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15419        if (!top15.length) return;
15420        var h = Math.max(320, top15.length * 36 + 80);
15421        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15422        if (!canvas) return;
15423        new Chart(canvas, {{
15424          type: 'bar',
15425          data: {{
15426            labels: top15.map(function(d){{ return d.lang; }}),
15427            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 }}]
15428          }},
15429          options: {{
15430            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15431            layout: {{ padding: {{ right: 72 }} }},
15432            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15433            scales: {{
15434              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15435              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15436            }}
15437          }},
15438          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15439        }});
15440      }});
15441    }})();
15442
15443    // Wire trend control selectors — re-render without re-fetching
15444    (function() {{
15445      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15446        var el = document.getElementById(id);
15447        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15448      }});
15449    }})();
15450
15451    function loadTrend() {{
15452      var url = '/api/metrics/history?limit=100';
15453      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15454      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15455        buildTrend(data);
15456        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15457        var btn = document.getElementById('multi-compare-trend-btn');
15458        if (btn) {{
15459          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15460          if (ids.length >= 2) {{
15461            btn.style.display = '';
15462            btn.onclick = function() {{
15463              // Reverse so oldest first (API returns newest first).
15464              var sorted = ids.slice().reverse();
15465              if (sorted.length === 2) {{
15466                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15467              }} else {{
15468                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15469              }}
15470            }};
15471          }} else {{
15472            btn.style.display = 'none';
15473          }}
15474        }}
15475      }}).catch(function(){{
15476        var trendEmpty = document.getElementById('trend-empty');
15477        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15478      }});
15479    }}
15480
15481    // Re-render charts on theme toggle
15482    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15483      setTimeout(function() {{
15484        ALL_CHARTS.forEach(function(c) {{
15485          if (c && c.options && c.options.scales) {{
15486            Object.values(c.options.scales).forEach(function(ax) {{
15487              if (ax.grid) ax.grid.color = clr();
15488              if (ax.ticks) ax.ticks.color = txtClr();
15489            }});
15490            c.update();
15491          }}
15492        }});
15493      }}, 80);
15494    }});
15495
15496    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15497    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15498    function tmExportMeta() {{
15499      var sel = document.getElementById('scope-sel');
15500      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15501      if (!proj || proj === '__all__') proj = 'All projects';
15502      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15503      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15504      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15505      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15506      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15507    }}
15508
15509    function exportTmXLSX() {{
15510      var D = currentLangTests;
15511      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15512      var t = tmExportMeta();
15513      function s2b(s) {{ return new TextEncoder().encode(s); }}
15514      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
15515      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; }}
15516      function crc32(d) {{
15517        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;}}}}
15518        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15519      }}
15520      // Store all cells as strings so Excel left-aligns uniformly.
15521      function cs(addr, val, bold) {{
15522        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15523      }}
15524      // Build an Excel Table XML definition for a given sheet range and columns.
15525      function makeTableXml(tblId, name, ref, cols) {{
15526        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15527        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15528        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15529        x+='<autoFilter ref="'+ref+'"/>';
15530        x+='<tableColumns count="'+cols.length+'">';
15531        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15532        x+='</tableColumns>';
15533        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15534        return x+'</table>';
15535      }}
15536      // Worksheet XML with optional Excel Table part reference.
15537      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15538        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15539        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15540        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15541        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15542        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15543        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>';}});
15544        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>';}}
15545        x+='</sheetData>';
15546        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15547        return x+'</worksheet>';
15548      }}
15549
15550      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15551      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15552      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15553      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15554      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15555      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15556
15557      // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15558      // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15559      var sheets=[];
15560
15561      // Sheet: Summary
15562      var sumHdr=['Metric','Value'];
15563      var sumRows=[
15564        ['Project / Scope', t.proj],
15565        ['Export Date', t.full],
15566        ['Test Functions', Number(totTests).toLocaleString()],
15567        ['Assertions', Number(totAssert).toLocaleString()],
15568        ['Test Suites', Number(totSuites).toLocaleString()],
15569        ['Languages with Tests', String(D.length)],
15570        ['Total Code Lines', Number(totCode).toLocaleString()],
15571        ['Average Density (per 1K)', String(avgDensity)],
15572      ];
15573      sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15574
15575      // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15576      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15577      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)];}});
15578      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15579      sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15580
15581      // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15582      var covDs=(typeof getDataset==='function')?getDataset():null;
15583      if(covDs&&covDs.has_coverage){{
15584        var covT=covDs.totals||{{}};
15585        var covSumHdr=['Metric','Value'];
15586        var covSumRows=[
15587          ['Line Coverage', (covT.cov_line||'0')+'%'],
15588          ['Function Coverage', (covT.cov_fn||'0')+'%'],
15589          ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15590        ];
15591        if(covDs.cov_tiers){{
15592          covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15593          covSumRows.push(['Files Moderate (50–79%)', String(covDs.cov_tiers.mid||0)]);
15594          covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15595        }}
15596        sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15597
15598        if(covDs.cov&&covDs.cov.length){{
15599          var covLangHdr=['Language','Line Coverage %'];
15600          var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15601          sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15602        }}
15603        if(covFileData&&covFileData.length){{
15604          var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15605          var covFileRows=covFileData.map(function(f){{
15606            var noFn=f.fn_pct<0;
15607            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)];
15608          }});
15609          sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15610        }}
15611      }}
15612
15613      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>';
15614      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>';
15615
15616      // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15617      var files=[];
15618      var ctOverrides='', wbSheetTags='', wbRelTags='';
15619      sheets.forEach(function(sh,i){{
15620        var n=i+1;
15621        var lastCol=col2l(sh.hdr.length);
15622        var ref='A1:'+lastCol+(sh.rows.length+1);
15623        var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15624        var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15625        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>';
15626        files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15627        files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15628        files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15629        ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15630        ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15631        wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15632        wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15633      }});
15634      var styleRid='rId'+(sheets.length+1);
15635      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>';
15636      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>';
15637      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>';
15638      files.unshift(
15639        {{name:'[Content_Types].xml',data:s2b(ct)}},
15640        {{name:'_rels/.rels',data:s2b(dotrels)}},
15641        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15642        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15643        {{name:'xl/styles.xml',data:s2b(styl)}}
15644      );
15645      var parts=[],offsets=[],total=0;
15646      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;}});
15647      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;}});
15648      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));
15649      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;}});
15650      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15651      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15652      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15653      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15654    }}
15655
15656    function exportTmPNG() {{
15657      // Map canvas IDs to display titles
15658      var CHART_TITLES = {{
15659        'canvas-trend':       'TEST COUNT TREND',
15660        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15661        'canvas-density':     'TEST DENSITY (per 1,000 code lines)',
15662        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15663        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15664        'canvas-files':       'TEST FILES BREAKDOWN',
15665        'canvas-composition': 'TEST COMPOSITION',
15666        'canvas-cov':         'LINE COVERAGE % BY LANGUAGE',
15667        'canvas-cov-tiers':   'COVERAGE TIER DISTRIBUTION'
15668      }};
15669      // Coverage canvases are only appended when the LCOV panel is visible (has data).
15670      var covPanelEl=document.getElementById('cov-panel');
15671      var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15672      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15673      if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15674      // Include only charts that actually rendered data. A "no data" chart has its
15675      // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15676      // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15677      function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15678      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15679      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15680      var t=tmExportMeta();
15681      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15682      var trendCanvas=document.getElementById('canvas-trend');
15683      var hasTrend=chartHasData(trendCanvas);
15684      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15685      var TOTAL_W=COLW*2+GAP;
15686      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15687      TREND_H=Math.min(Math.max(200,TREND_H),340);
15688      // Per-row chart heights (2-col grid)
15689      var gridRows=Math.ceil(gridCanvases.length/2);
15690      var rowHeights=[];
15691      for(var ri=0;ri<gridRows;ri++){{
15692        var rh=240;
15693        for(var ci=0;ci<2;ci++){{
15694          var cv=gridCanvases[ri*2+ci];
15695          if(cv&&cv.width>0){{
15696            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15697            rh=Math.max(rh,Math.min(420,nat));
15698          }}
15699        }}
15700        rowHeights.push(rh);
15701      }}
15702      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15703      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15704      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15705      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15706      var ctx=out.getContext('2d');
15707      var cs2=getComputedStyle(document.body);
15708      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15709      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15710      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15711
15712      // Background
15713      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15714
15715      // Orange header block
15716      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15717      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15718      ctx.fillText('Test Metrics — '+t.proj,22,42);
15719      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15720      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15721      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15722
15723      // Helper: draw a section title label
15724      function drawTitle(label, x, y, w) {{
15725        ctx.save();
15726        ctx.fillStyle=oxide;
15727        ctx.font='700 11px '+TM_FONT;
15728        ctx.textBaseline='middle';
15729        ctx.textAlign='left';
15730        ctx.letterSpacing='0.07em';
15731        ctx.fillText(label, x+2, y+TITLE_H/2);
15732        // Underline
15733        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15734        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15735        ctx.globalAlpha=1;
15736        ctx.restore();
15737      }}
15738
15739      var yOff=HEADER_H;
15740
15741      // Trend chart (full width)
15742      if(hasTrend){{
15743        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15744        yOff+=TITLE_H;
15745        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15746        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15747        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15748        ctx.drawImage(surf,0,yOff);
15749        yOff+=TREND_H+ROW_PAD;
15750      }}
15751
15752      // Grid charts (2-col), each cell gets title + chart
15753      for(var gi=0;gi<gridRows;gi++){{
15754        var rh2=rowHeights[gi];
15755        // Draw row titles and charts
15756        for(var gci=0;gci<2;gci++){{
15757          var idx2=gi*2+gci;
15758          if(idx2>=gridCanvases.length)continue;
15759          var gcv=gridCanvases[idx2];
15760          var gx=gci*(COLW+GAP);
15761          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15762        }}
15763        yOff+=TITLE_H;
15764        for(var gci2=0;gci2<2;gci2++){{
15765          var idx3=gi*2+gci2;
15766          if(idx3>=gridCanvases.length)continue;
15767          var gcv2=gridCanvases[idx3];
15768          var gx2=gci2*(COLW+GAP);
15769          var natW=gcv2.width,natH=gcv2.height;
15770          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15771          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15772          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15773          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15774          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15775          ctx.drawImage(surf2,gx2,yOff);
15776        }}
15777        yOff+=rh2+ROW_PAD;
15778      }}
15779
15780      // Dark footer
15781      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15782      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15783      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15784
15785      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15786      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15787    }}
15788
15789    function exportTmPDF(ev) {{
15790      var D=currentLangTests;
15791      var t=tmExportMeta();
15792      var strips=document.querySelectorAll('.summary-strip');
15793      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15794      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15795      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15796      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15797      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15798      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15799      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15800      var rows='';
15801      (D||[]).forEach(function(d){{
15802        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15803          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15804          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15805          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15806          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15807          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15808          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15809      }});
15810      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15811        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15812        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15813        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15814        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15815        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15816        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15817      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>';
15818      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15819        +'html,body{{height:100%;margin:0;}}'
15820        +'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;}}'
15821        +'.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;}}'
15822        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15823        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15824        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15825        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15826        +'.rep-body{{padding:20px 32px;flex:1;}}'
15827        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15828        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15829        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15830        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15831        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15832        +'.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;}}'
15833        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15834        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15835        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15836        +'.n{{text-align:right;}}'
15837        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15838        +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15839        +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15840        +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15841        +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15842        +'.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;}}'
15843        +'</style>';
15844      // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15845      var covDs=(typeof getDataset==='function')?getDataset():null;
15846      var covHtml='';
15847      if(covDs&&covDs.has_coverage){{
15848        var covT=covDs.totals||{{}};
15849        covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15850          +'<div class="cov-strip">'
15851          +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15852          +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15853          +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15854          +'</div>';
15855        if(covFileData&&covFileData.length){{
15856          var cfrows='';
15857          covFileData.forEach(function(f){{
15858            var noFn=f.fn_pct<0;
15859            cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15860              +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15861              +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15862              +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15863              +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15864          }});
15865          covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15866            +'<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>';
15867        }}
15868      }}
15869      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15870        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
15871        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15872        +'<div class="rep-body">'+statsHtml
15873        +'<div class="section-hdr">Language Breakdown</div>'
15874        +tableHtml+covHtml+'</div>'
15875        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15876        +'</body></html>';
15877      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15878      var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15879      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15880    }}
15881
15882    (function() {{
15883      // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
15884      // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
15885      var xBtn=document.getElementById('tm-export-xlsx-btn');
15886      var pngBtn=document.getElementById('tm-export-png-btn');
15887      var pdfBtn=document.getElementById('tm-export-pdf-btn');
15888      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15889      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15890      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15891    }})();
15892
15893    applyScope();
15894  }})();
15895  </script>
15896  <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>
15897  {toast_assets}
15898</body>
15899</html>"#,
15900    );
15901    (
15902        [(axum::http::header::CACHE_CONTROL, "no-store")],
15903        Html(html),
15904    )
15905        .into_response()
15906}
15907
15908// ── Embeddable widget ─────────────────────────────────────────────────────────
15909// Protected. Returns a self-contained HTML page suitable for iframing inside
15910// Jenkins build summaries, Confluence iframe macros, or Jira panels.
15911//
15912// GET /embed/summary?run_id=<uuid>&theme=dark
15913
15914#[derive(Deserialize)]
15915struct EmbedQuery {
15916    run_id: Option<String>,
15917    theme: Option<String>,
15918}
15919
15920async fn embed_handler(
15921    State(state): State<AppState>,
15922    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15923    Query(query): Query<EmbedQuery>,
15924) -> Response {
15925    let entry = {
15926        let reg = state.registry.lock().await;
15927        query.run_id.as_ref().map_or_else(
15928            || reg.entries.first().cloned(),
15929            |id| reg.find_by_run_id(id).cloned(),
15930        )
15931    };
15932
15933    let Some(entry) = entry else {
15934        return Html(
15935            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15936                .to_string(),
15937        )
15938        .into_response();
15939    };
15940
15941    let dark = query.theme.as_deref() == Some("dark");
15942    let languages: Vec<(String, u64, u64)> = entry
15943        .json_path
15944        .as_ref()
15945        .and_then(|p| read_json(p).ok())
15946        .map(|run| {
15947            run.totals_by_language
15948                .iter()
15949                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15950                .collect()
15951        })
15952        .unwrap_or_default();
15953
15954    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15955}
15956
15957fn render_embed_widget(
15958    entry: &RegistryEntry,
15959    languages: &[(String, u64, u64)],
15960    dark: bool,
15961    csp_nonce: &str,
15962) -> String {
15963    let s = &entry.summary;
15964    let total = s.code_lines + s.comment_lines + s.blank_lines;
15965    let code_pct = s
15966        .code_lines
15967        .checked_mul(100)
15968        .and_then(|n| n.checked_div(total))
15969        .unwrap_or(0);
15970
15971    let (bg, fg, surface, muted, border) = if dark {
15972        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15973    } else {
15974        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15975    };
15976
15977    let mut lang_rows = String::new();
15978    for (name, files, code) in languages {
15979        write!(
15980            lang_rows,
15981            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15982            escape_html(name),
15983            format_number(*files),
15984            format_number(*code),
15985        )
15986        .ok();
15987    }
15988
15989    let lang_table = if lang_rows.is_empty() {
15990        String::new()
15991    } else {
15992        format!(
15993            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15994        )
15995    };
15996
15997    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15998    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15999    let project_esc = escape_html(&entry.project_label);
16000    let code_lines = format_number(s.code_lines);
16001    let comment_lines = format_number(s.comment_lines);
16002    let files = format_number(s.files_analyzed);
16003    let code_raw = s.code_lines;
16004    let comment_raw = s.comment_lines;
16005    let blank_raw = s.blank_lines;
16006
16007    format!(
16008        r#"<!doctype html>
16009<html lang="en">
16010<head>
16011  <meta charset="utf-8">
16012  <meta name="viewport" content="width=device-width,initial-scale=1">
16013  <title>OxideSLOC &mdash; {project_esc}</title>
16014  <script src="/static/chart.js"></script>
16015  <style nonce="{csp_nonce}">
16016    *{{box-sizing:border-box;margin:0;padding:0}}
16017    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16018    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16019    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16020    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16021    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16022    .card .v{{font-size:18px;font-weight:700}}
16023    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16024    .row{{display:flex;gap:12px;align-items:flex-start}}
16025    .pie{{width:120px;height:120px;flex-shrink:0}}
16026    .lt{{border-collapse:collapse;width:100%;flex:1}}
16027    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16028    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16029    .n{{text-align:right}}
16030    .footer{{margin-top:10px;color:{muted};font-size:10px}}
16031  </style>
16032</head>
16033<body>
16034  <h2>{project_esc}</h2>
16035  <div class="sub">{timestamp} &middot; run {run_short}</div>
16036  <div class="cards">
16037    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16038    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16039    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16040    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16041  </div>
16042  <div class="row">
16043    <canvas class="pie" id="c"></canvas>
16044    {lang_table}
16045  </div>
16046  <div class="footer">oxide-sloc</div>
16047  <script nonce="{csp_nonce}">
16048    new Chart(document.getElementById('c'),{{
16049      type:'doughnut',
16050      data:{{
16051        labels:['Code','Comments','Blank'],
16052        datasets:[{{
16053          data:[{code_raw},{comment_raw},{blank_raw}],
16054          backgroundColor:['#4a78ee','#b35428','#aaa'],
16055          borderWidth:0
16056        }}]
16057      }},
16058      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16059    }});
16060  </script>
16061</body>
16062</html>"#
16063    )
16064}
16065
16066/// Returns a process-wide mutex unique to `dir`, so that two requests writing
16067/// artifacts into the *same* output directory (e.g. re-ingesting an identical
16068/// `run_id`) serialize instead of corrupting each other's files. Directories that
16069/// differ never contend, so legitimate parallel analyses keep their throughput.
16070fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16071    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16072        OnceLock::new();
16073    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16074    let mut guard = map
16075        .lock()
16076        .unwrap_or_else(std::sync::PoisonError::into_inner);
16077    guard
16078        .entry(dir.to_path_buf())
16079        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16080        .clone()
16081}
16082
16083#[allow(clippy::too_many_lines)]
16084fn persist_run_artifacts(
16085    run: &sloc_core::AnalysisRun,
16086    report_html: &str,
16087    run_dir: &Path,
16088    report_title: &str,
16089    file_stem: &str,
16090    result_context: RunResultContext,
16091) -> Result<(RunArtifacts, PendingPdf)> {
16092    // Serialize concurrent writers targeting this same output directory so their
16093    // file writes cannot interleave and corrupt one another.
16094    let dir_lock = output_dir_lock(run_dir);
16095    let _dir_guard = dir_lock
16096        .lock()
16097        .unwrap_or_else(std::sync::PoisonError::into_inner);
16098
16099    // Root dir + organised subdirectories.
16100    let html_dir = run_dir.join("html");
16101    let pdf_dir = run_dir.join("pdf");
16102    let excel_dir = run_dir.join("excel");
16103    let json_dir = run_dir.join("json");
16104    let submodules_dir = run_dir.join("submodules");
16105    for dir in &[
16106        run_dir,
16107        &html_dir,
16108        &pdf_dir,
16109        &excel_dir,
16110        &json_dir,
16111        &submodules_dir,
16112    ] {
16113        fs::create_dir_all(dir)
16114            .with_context(|| format!("failed to create directory {}", dir.display()))?;
16115    }
16116
16117    // HTML report in html/.
16118    let html_path = {
16119        let path = html_dir.join(format!("report_{file_stem}.html"));
16120        fs::write(&path, report_html)
16121            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16122        Some(path)
16123    };
16124
16125    // JSON result in json/.
16126    let json_path = {
16127        let path = json_dir.join(format!("result_{file_stem}.json"));
16128        let json = serde_json::to_string_pretty(run)
16129            .context("failed to serialize analysis run to JSON")?;
16130        fs::write(&path, json)
16131            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16132        Some(path)
16133    };
16134
16135    // PDF in pdf/.
16136    let (pdf_path, pending_pdf) = {
16137        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16138        match write_pdf_from_run(run, &pdf_dest) {
16139            Ok(()) => {
16140                eprintln!(
16141                    "[oxide-sloc][pdf] native PDF written to {}",
16142                    pdf_dest.display()
16143                );
16144                (Some(pdf_dest), None)
16145            }
16146            Err(native_err) => {
16147                eprintln!(
16148                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16149                );
16150                let source_html_path = html_path
16151                    .as_ref()
16152                    .expect("html_path always Some here")
16153                    .clone();
16154                let pending = Some((source_html_path, pdf_dest.clone(), false));
16155                (Some(pdf_dest), pending)
16156            }
16157        }
16158    };
16159
16160    // CSV and XLSX in excel/.
16161    let csv_path = {
16162        let path = excel_dir.join(format!("report_{file_stem}.csv"));
16163        if let Err(e) = sloc_report::write_csv(run, &path) {
16164            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16165            None
16166        } else {
16167            Some(path)
16168        }
16169    };
16170
16171    let xlsx_path = {
16172        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16173        if let Err(e) = sloc_report::write_xlsx(run, &path) {
16174            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16175            None
16176        } else {
16177            Some(path)
16178        }
16179    };
16180
16181    // Scan config in json/.
16182    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16183
16184    // Eagerly generate sub-reports before index.html so relative links work.
16185    if run.effective_configuration.discovery.submodule_breakdown {
16186        let run_id = &run.tool.run_id;
16187        for s in &run.submodule_summaries {
16188            build_submodule_row(s, run, run_id, run_dir);
16189        }
16190    }
16191
16192    // index.html at root — offline static export of the result-page dashboard.
16193    generate_offline_index(
16194        run,
16195        run_dir,
16196        file_stem,
16197        html_path.as_deref(),
16198        pdf_path.as_deref(),
16199        json_path.as_deref(),
16200        scan_config_path.as_deref(),
16201        &result_context,
16202    );
16203
16204    Ok((
16205        RunArtifacts {
16206            output_dir: run_dir.to_path_buf(),
16207            html_path,
16208            pdf_path,
16209            json_path,
16210            csv_path,
16211            xlsx_path,
16212            scan_config_path,
16213            report_title: report_title.to_string(),
16214            result_context,
16215        },
16216        pending_pdf,
16217    ))
16218}
16219
16220/// Render a static offline result-page dashboard and write it as `index.html` at
16221/// the root of the run output directory so business users can open it from disk.
16222#[allow(clippy::too_many_arguments)]
16223#[allow(clippy::too_many_lines)]
16224#[allow(clippy::similar_names)]
16225fn generate_offline_index(
16226    run: &sloc_core::AnalysisRun,
16227    run_dir: &Path,
16228    file_stem: &str,
16229    html_path: Option<&Path>,
16230    pdf_path: Option<&Path>,
16231    json_path: Option<&Path>,
16232    scan_config_path: Option<&Path>,
16233    result_context: &RunResultContext,
16234) {
16235    let prev_entry = &result_context.prev_entry;
16236    let prev_scan_count = result_context.prev_scan_count;
16237    let project_path = &result_context.project_path;
16238
16239    let scan_delta = prev_entry.as_ref().and_then(|prev| {
16240        prev.json_path
16241            .as_ref()
16242            .and_then(|p| read_json(p).ok())
16243            .map(|prev_run| compute_delta(&prev_run, run))
16244    });
16245
16246    let files_analyzed = run.per_file_records.len() as u64;
16247    let files_skipped = run.skipped_file_records.len() as u64;
16248    let totals = sum_lang_totals(run);
16249
16250    let DeltaFields {
16251        prev_fa_str,
16252        prev_fs_str,
16253        prev_pl_str,
16254        prev_cl_str,
16255        prev_cml_str,
16256        prev_bl_str,
16257        delta_fa_str,
16258        delta_fa_class,
16259        delta_fs_str,
16260        delta_fs_class,
16261        delta_pl_str,
16262        delta_pl_class,
16263        delta_cl_str,
16264        delta_cl_class,
16265        delta_cml_str,
16266        delta_cml_class,
16267        delta_bl_str,
16268        delta_bl_class,
16269        delta_lines_added,
16270        delta_lines_removed,
16271        delta_lines_net_str,
16272        delta_lines_net_class,
16273    } = compute_delta_fields(
16274        prev_entry.as_ref(),
16275        &totals,
16276        files_analyzed,
16277        files_skipped,
16278        scan_delta.as_ref(),
16279    );
16280
16281    let git_commit_url = git_commit_url_for(run);
16282    let git_branch_url = git_branch_url_for(run);
16283    let scan_performed_by = scan_performed_by(run);
16284
16285    // Convert absolute path to relative from run_dir (for file:// navigation).
16286    let make_rel = |p: Option<&Path>| -> Option<String> {
16287        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16288            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16289    };
16290
16291    let run_id = &run.tool.run_id;
16292
16293    // Submodule rows with relative paths into submodules/.
16294    let submodule_rows: Vec<SubmoduleRow> = run
16295        .submodule_summaries
16296        .iter()
16297        .map(|s| {
16298            let safe = sanitize_project_label(&s.name);
16299            let key = format!("sub_{safe}");
16300            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16301            SubmoduleRow {
16302                name: s.name.clone(),
16303                relative_path: s.relative_path.clone(),
16304                files_analyzed: s.files_analyzed,
16305                code_lines: s.code_lines,
16306                comment_lines: s.comment_lines,
16307                blank_lines: s.blank_lines,
16308                total_physical_lines: s.total_physical_lines,
16309                html_url: if sub_path.exists() {
16310                    Some(format!("submodules/{key}.html"))
16311                } else {
16312                    None
16313                },
16314            }
16315        })
16316        .collect();
16317
16318    let lang_chart_json = build_lang_chart_json(run);
16319
16320    let scan_config_rel =
16321        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16322
16323    let template = ResultTemplate {
16324        version: env!("CARGO_PKG_VERSION"),
16325        report_title: run.effective_configuration.reporting.report_title.clone(),
16326        project_path: project_path.clone(),
16327        output_dir: display_path(run_dir),
16328        run_id: run_id.clone(),
16329        run_id_short: run_id
16330            .split('-')
16331            .next_back()
16332            .unwrap_or(run_id)
16333            .chars()
16334            .take(7)
16335            .collect(),
16336        files_analyzed,
16337        files_skipped,
16338        physical_lines: totals.physical_lines,
16339        code_lines: totals.code_lines,
16340        comment_lines: totals.comment_lines,
16341        blank_lines: totals.blank_lines,
16342        mixed_lines: totals.mixed_lines,
16343        functions: totals.functions,
16344        classes: totals.classes,
16345        variables: totals.variables,
16346        imports: totals.imports,
16347        html_url: make_rel(html_path),
16348        pdf_url: make_rel(pdf_path),
16349        json_url: make_rel(json_path),
16350        html_download_url: make_rel(html_path),
16351        pdf_download_url: make_rel(pdf_path),
16352        json_download_url: make_rel(json_path),
16353        html_path: html_path.map(display_path),
16354        json_path: json_path.map(display_path),
16355        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16356        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16357        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16358        prev_fa_str,
16359        prev_fs_str,
16360        prev_pl_str,
16361        prev_cl_str,
16362        prev_cml_str,
16363        prev_bl_str,
16364        delta_fa_str,
16365        delta_fa_class,
16366        delta_fs_str,
16367        delta_fs_class,
16368        delta_pl_str,
16369        delta_pl_class,
16370        delta_cl_str,
16371        delta_cl_class,
16372        delta_cml_str,
16373        delta_cml_class,
16374        delta_bl_str,
16375        delta_bl_class,
16376        delta_lines_added,
16377        delta_lines_removed,
16378        delta_lines_net_str,
16379        delta_lines_net_class,
16380        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16381        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16382        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16383        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16384        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16385        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16386        git_branch: run.git_branch.clone(),
16387        git_branch_url,
16388        git_commit: run.git_commit_short.clone(),
16389        git_commit_long: run.git_commit_long.clone(),
16390        git_author: run.git_commit_author.clone(),
16391        git_commit_url,
16392        scan_performed_by,
16393        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16394        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16395        os_display: format!(
16396            "{} / {}",
16397            run.environment.operating_system, run.environment.architecture
16398        ),
16399        test_count: run.summary_totals.test_count,
16400        test_assertion_count: run.summary_totals.test_assertion_count,
16401        current_scan_number: prev_scan_count + 1,
16402        prev_scan_count,
16403        submodule_rows,
16404        pdf_generating: false,
16405        scan_config_url: scan_config_rel,
16406        lang_chart_json,
16407        scatter_chart_json: build_scatter_chart_json(run),
16408        semantic_chart_json: build_semantic_chart_json(run),
16409        submodule_chart_json: build_submodule_chart_json(run),
16410        has_submodule_data: !run.submodule_summaries.is_empty(),
16411        has_semantic_data: run
16412            .totals_by_language
16413            .iter()
16414            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16415        csp_nonce: String::new(),
16416        confluence_configured: false,
16417        server_mode: false,
16418        report_header_footer: run
16419            .effective_configuration
16420            .reporting
16421            .report_header_footer
16422            .clone(),
16423        is_offline: true,
16424        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16425        lsloc: run.summary_totals.lsloc,
16426        uloc: run.uloc,
16427        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16428        duplicate_group_count: run.duplicate_groups.len(),
16429        has_cocomo: run.cocomo.is_some(),
16430        cocomo_effort_str: run
16431            .cocomo
16432            .as_ref()
16433            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16434        cocomo_duration_str: run
16435            .cocomo
16436            .as_ref()
16437            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16438        cocomo_staff_str: run
16439            .cocomo
16440            .as_ref()
16441            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16442        cocomo_ksloc_str: run
16443            .cocomo
16444            .as_ref()
16445            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16446        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16447            || "Organic".to_string(),
16448            |c| cocomo_mode_label(c.mode).to_string(),
16449        ),
16450        cocomo_mode_tooltip: run
16451            .cocomo
16452            .as_ref()
16453            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16454        complexity_alert: 0,
16455        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16456        cov_line_pct: cov_pct_str(
16457            run.summary_totals.coverage_lines_hit,
16458            run.summary_totals.coverage_lines_found,
16459        ),
16460        cov_fn_pct: cov_pct_str(
16461            run.summary_totals.coverage_functions_hit,
16462            run.summary_totals.coverage_functions_found,
16463        ),
16464        cov_branch_pct: cov_pct_str(
16465            run.summary_totals.coverage_branches_hit,
16466            run.summary_totals.coverage_branches_found,
16467        ),
16468        cov_lines_summary: cov_lines_summary_str(
16469            run.summary_totals.coverage_lines_hit,
16470            run.summary_totals.coverage_lines_found,
16471        ),
16472    };
16473
16474    if let Ok(html) = template.render() {
16475        // Inline the brand + watermark logos as data URIs: a file:// page has no
16476        // server to resolve the /images/logo/* routes, so without this the top-left
16477        // logo and the repeated "Oxide" background watermark render as broken images.
16478        let html = inline_offline_logos(&html);
16479        let index_path = run_dir.join("index.html");
16480        if let Err(e) = fs::write(&index_path, html) {
16481            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16482        }
16483    }
16484}
16485
16486/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
16487/// offline `index.html` displays the brand logo and background watermark when
16488/// opened directly from disk (file://), where the `/images/...` routes do not exist.
16489fn inline_offline_logos(html: &str) -> String {
16490    use base64::Engine;
16491    let text_uri = format!(
16492        "data:image/png;base64,{}",
16493        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16494    );
16495    let small_uri = format!(
16496        "data:image/png;base64,{}",
16497        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16498    );
16499    html.replace("/images/logo/logo-text.png", &text_uri)
16500        .replace("/images/logo/small-logo.png", &small_uri)
16501}
16502
16503/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
16504/// then root (old flat layout), for backwards compatibility.
16505fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16506    // New layout: json/scan-config_*.json
16507    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16508        return Some(found);
16509    }
16510    // Old flat layout: scan-config.json or scan-config_*.json at root
16511    find_scan_config_in_dir_flat(dir)
16512}
16513
16514fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16515    let exact = dir.join("scan-config.json");
16516    if exact.exists() {
16517        return Some(exact);
16518    }
16519    fs::read_dir(dir).ok().and_then(|entries| {
16520        entries
16521            .filter_map(std::result::Result::ok)
16522            .find(|e| {
16523                let name = e.file_name();
16524                let name = name.to_string_lossy();
16525                name.starts_with("scan-config") && name.ends_with(".json")
16526            })
16527            .map(|e| e.path())
16528    })
16529}
16530
16531// ── Config export / import ────────────────────────────────────────────────────
16532
16533/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
16534/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
16535#[derive(Deserialize)]
16536struct ExportPdfRequest {
16537    html: String,
16538    #[serde(default)]
16539    filename: Option<String>,
16540}
16541
16542async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16543    let html_content = body.html;
16544    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16545    if html_content.is_empty() {
16546        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16547    }
16548    // Write HTML to a temp file, run headless Chrome PDF export, read result.
16549    let tmp_dir = std::env::temp_dir();
16550    let html_path = tmp_dir.join(format!(
16551        "sloc-export-{}.html",
16552        uuid::Uuid::new_v4().simple()
16553    ));
16554    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16555    if let Err(e) = std::fs::write(&html_path, &html_content) {
16556        return (
16557            StatusCode::INTERNAL_SERVER_ERROR,
16558            format!("Failed to write temp HTML: {e}"),
16559        )
16560            .into_response();
16561    }
16562    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16563    let _ = std::fs::remove_file(&html_path);
16564    if let Err(e) = pdf_result {
16565        let _ = std::fs::remove_file(&pdf_path);
16566        return (
16567            StatusCode::INTERNAL_SERVER_ERROR,
16568            format!("PDF generation failed: {e}"),
16569        )
16570            .into_response();
16571    }
16572    let pdf_bytes = match std::fs::read(&pdf_path) {
16573        Ok(b) => b,
16574        Err(e) => {
16575            let _ = std::fs::remove_file(&pdf_path);
16576            return (
16577                StatusCode::INTERNAL_SERVER_ERROR,
16578                format!("Failed to read PDF: {e}"),
16579            )
16580                .into_response();
16581        }
16582    };
16583    let _ = std::fs::remove_file(&pdf_path);
16584    let safe_name: String = filename
16585        .chars()
16586        .map(|c| {
16587            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16588                c
16589            } else {
16590                '_'
16591            }
16592        })
16593        .collect();
16594    let disposition = format!("attachment; filename=\"{safe_name}\"");
16595    (
16596        [
16597            (header::CONTENT_TYPE, "application/pdf".to_string()),
16598            (header::CONTENT_DISPOSITION, disposition),
16599        ],
16600        pdf_bytes,
16601    )
16602        .into_response()
16603}
16604
16605async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16606    let toml_str = match toml::to_string_pretty(&state.base_config) {
16607        Ok(s) => s,
16608        Err(e) => {
16609            return (
16610                StatusCode::INTERNAL_SERVER_ERROR,
16611                format!("serialization error: {e}"),
16612            )
16613                .into_response();
16614        }
16615    };
16616    (
16617        [
16618            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16619            (
16620                header::CONTENT_DISPOSITION,
16621                "attachment; filename=\".oxide-sloc.toml\"",
16622            ),
16623        ],
16624        toml_str,
16625    )
16626        .into_response()
16627}
16628
16629#[derive(Serialize)]
16630struct OkResponse {
16631    ok: bool,
16632}
16633
16634#[derive(Serialize)]
16635struct SaveProfileResponse {
16636    ok: bool,
16637    id: String,
16638}
16639
16640#[derive(Serialize)]
16641struct ProfileListResponse {
16642    profiles: Vec<ScanProfile>,
16643}
16644
16645#[derive(Serialize)]
16646struct ImportConfigResponse {
16647    ok: bool,
16648    config: sloc_config::AppConfig,
16649}
16650
16651#[derive(Deserialize)]
16652struct ImportConfigBody {
16653    toml: String,
16654}
16655
16656async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16657    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16658        Ok(config) => {
16659            if let Err(e) = config.validate() {
16660                return error::unprocessable_entity(&e.to_string());
16661            }
16662            Json(ImportConfigResponse { ok: true, config }).into_response()
16663        }
16664        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16665    }
16666}
16667
16668// ── Scan profiles API ─────────────────────────────────────────────────────────
16669
16670async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16671    let store = state.scan_profiles.lock().await;
16672    Json(ProfileListResponse {
16673        profiles: store.profiles.clone(),
16674    })
16675}
16676
16677#[derive(Deserialize)]
16678struct SaveScanProfileBody {
16679    name: String,
16680    params: serde_json::Value,
16681}
16682
16683async fn api_save_scan_profile(
16684    State(state): State<AppState>,
16685    Json(body): Json<SaveScanProfileBody>,
16686) -> impl IntoResponse {
16687    if body.name.trim().is_empty() {
16688        return error::bad_request("name must not be empty");
16689    }
16690
16691    let id = uuid::Uuid::new_v4().to_string();
16692    let profile = ScanProfile {
16693        id: id.clone(),
16694        name: body.name.trim().to_string(),
16695        created_at: chrono::Utc::now().to_rfc3339(),
16696        params: body.params,
16697    };
16698
16699    let mut store = state.scan_profiles.lock().await;
16700    store.profiles.push(profile);
16701    if let Err(e) = store.save(&state.scan_profiles_path) {
16702        tracing::warn!("failed to persist scan profiles: {e}");
16703    }
16704    drop(store);
16705
16706    (
16707        StatusCode::CREATED,
16708        Json(SaveProfileResponse { ok: true, id }),
16709    )
16710        .into_response()
16711}
16712
16713async fn api_delete_scan_profile(
16714    State(state): State<AppState>,
16715    AxumPath(id): AxumPath<String>,
16716) -> impl IntoResponse {
16717    let mut store = state.scan_profiles.lock().await;
16718    let before = store.profiles.len();
16719    store.profiles.retain(|p| p.id != id);
16720    if store.profiles.len() == before {
16721        drop(store);
16722        return error::not_found("profile not found");
16723    }
16724    if let Err(e) = store.save(&state.scan_profiles_path) {
16725        tracing::warn!("failed to persist scan profiles: {e}");
16726    }
16727    drop(store);
16728    Json(OkResponse { ok: true }).into_response()
16729}
16730
16731fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16732    let value = raw.unwrap_or("out/web").trim();
16733    let path = if value.is_empty() {
16734        PathBuf::from("out/web")
16735    } else {
16736        PathBuf::from(value)
16737    };
16738
16739    if path.is_absolute() {
16740        path
16741    } else {
16742        workspace_root().join(path)
16743    }
16744}
16745
16746/// Derive the directory that holds remote-repo clones from the output root.
16747fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16748    std::env::var("SLOC_GIT_CLONES_DIR")
16749        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16750}
16751
16752/// Build a deterministic filesystem path for a cloned remote repository.
16753/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16754pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16755    let safe: String = repo_url
16756        .chars()
16757        .map(|c| {
16758            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16759                c
16760            } else {
16761                '_'
16762            }
16763        })
16764        .take(80)
16765        .collect();
16766    clones_dir.join(safe)
16767}
16768
16769/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
16770/// Runs synchronously — call from `tokio::task::spawn_blocking`.
16771pub(crate) fn scan_path_to_artifacts(
16772    scan_path: &Path,
16773    base_config: &AppConfig,
16774    label: &str,
16775) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16776    let mut config = base_config.clone();
16777    config.discovery.root_paths = vec![scan_path.to_path_buf()];
16778    label.clone_into(&mut config.reporting.report_title);
16779    let run = analyze(&config, "git", None, None)?;
16780    let html = render_html(&run)?;
16781    let run_id = run.tool.run_id.clone();
16782    let project_label = sanitize_project_label(label);
16783    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16784    let file_stem = {
16785        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16786        if commit.is_empty() {
16787            project_label
16788        } else {
16789            format!("{project_label}_{commit}")
16790        }
16791    };
16792    let (artifacts, _pending_pdf) = persist_run_artifacts(
16793        &run,
16794        &html,
16795        &output_dir,
16796        label,
16797        &file_stem,
16798        RunResultContext::default(),
16799    )?;
16800    Ok((run_id, artifacts, run))
16801}
16802
16803/// Re-spawn background poll tasks for any polling schedules saved to disk.
16804async fn restart_poll_schedules(state: &AppState) {
16805    let store = state.schedules.lock().await;
16806    let poll_schedules: Vec<_> = store
16807        .schedules
16808        .iter()
16809        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16810        .cloned()
16811        .collect();
16812    drop(store);
16813    for schedule in poll_schedules {
16814        let interval = schedule.interval_secs.unwrap_or(300);
16815        let st = state.clone();
16816        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16817    }
16818}
16819
16820/// Warn at startup when GitLab webhook schedules exist but native TLS is not
16821/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
16822/// header (no HMAC over the body), so the token is exposed in cleartext unless
16823/// the transport is encrypted. This is only an advisory — TLS may be terminated
16824/// by an upstream reverse proxy, in which case the warning can be ignored.
16825async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16826    if state.tls_enabled {
16827        return;
16828    }
16829    let store = state.schedules.lock().await;
16830    let has_gitlab_webhook = store.schedules.iter().any(|s| {
16831        s.kind == sloc_git::ScanScheduleKind::Webhook
16832            && s.provider == sloc_git::ScanScheduleProvider::GitLab
16833    });
16834    drop(store);
16835    if has_gitlab_webhook {
16836        tracing::warn!(
16837            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16838             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16839             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16840             proxy so the token is not exposed in cleartext."
16841        );
16842    }
16843}
16844
16845fn split_patterns(raw: Option<&str>) -> Vec<String> {
16846    raw.unwrap_or("")
16847        .lines()
16848        .flat_map(|line| line.split(','))
16849        .map(str::trim)
16850        .filter(|part| !part.is_empty())
16851        .map(ToOwned::to_owned)
16852        .collect()
16853}
16854
16855#[must_use]
16856pub fn build_sub_run(
16857    parent: &AnalysisRun,
16858    sub: &sloc_core::SubmoduleSummary,
16859    parent_path: &str,
16860) -> AnalysisRun {
16861    let sub_files: Vec<_> = parent
16862        .per_file_records
16863        .iter()
16864        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16865        .cloned()
16866        .collect();
16867    let mut config = parent.effective_configuration.clone();
16868    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16869
16870    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
16871    let mut functions = 0u64;
16872    let mut classes = 0u64;
16873    let mut variables = 0u64;
16874    let mut imports = 0u64;
16875    let mut test_count = 0u64;
16876    let mut test_assertion_count = 0u64;
16877    let mut test_suite_count = 0u64;
16878    let mut mixed_lines_separate = 0u64;
16879    let mut coverage_lines_found = 0u64;
16880    let mut coverage_lines_hit = 0u64;
16881    let mut coverage_functions_found = 0u64;
16882    let mut coverage_functions_hit = 0u64;
16883    let mut coverage_branches_found = 0u64;
16884    let mut coverage_branches_hit = 0u64;
16885    for r in &sub_files {
16886        functions += r.raw_line_categories.functions;
16887        classes += r.raw_line_categories.classes;
16888        variables += r.raw_line_categories.variables;
16889        imports += r.raw_line_categories.imports;
16890        test_count += r.raw_line_categories.test_count;
16891        test_assertion_count += r.raw_line_categories.test_assertion_count;
16892        test_suite_count += r.raw_line_categories.test_suite_count;
16893        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16894        if let Some(cov) = &r.coverage {
16895            coverage_lines_found += u64::from(cov.lines_found);
16896            coverage_lines_hit += u64::from(cov.lines_hit);
16897            coverage_functions_found += u64::from(cov.functions_found);
16898            coverage_functions_hit += u64::from(cov.functions_hit);
16899            coverage_branches_found += u64::from(cov.branches_found);
16900            coverage_branches_hit += u64::from(cov.branches_hit);
16901        }
16902    }
16903
16904    AnalysisRun {
16905        tool: parent.tool.clone(),
16906        environment: parent.environment.clone(),
16907        effective_configuration: config,
16908        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16909        summary_totals: SummaryTotals {
16910            files_considered: sub.files_analyzed,
16911            files_analyzed: sub.files_analyzed,
16912            files_skipped: 0,
16913            total_physical_lines: sub.total_physical_lines,
16914            code_lines: sub.code_lines,
16915            comment_lines: sub.comment_lines,
16916            blank_lines: sub.blank_lines,
16917            mixed_lines_separate,
16918            functions,
16919            classes,
16920            variables,
16921            imports,
16922            test_count,
16923            test_assertion_count,
16924            test_suite_count,
16925            coverage_lines_found,
16926            coverage_lines_hit,
16927            coverage_functions_found,
16928            coverage_functions_hit,
16929            coverage_branches_found,
16930            coverage_branches_hit,
16931            cyclomatic_complexity: 0,
16932            lsloc: None,
16933            ..Default::default()
16934        },
16935        totals_by_language: sub.language_summaries.clone(),
16936        per_file_records: sub_files,
16937        skipped_file_records: vec![],
16938        warnings: vec![],
16939        submodule_summaries: vec![],
16940        git_commit_short: sub.git_commit_short.clone(),
16941        git_commit_long: sub.git_commit_long.clone(),
16942        git_branch: sub.git_branch.clone(),
16943        git_commit_author: sub.git_commit_author.clone(),
16944        git_commit_date: sub.git_commit_date.clone(),
16945        git_tags: None,
16946        git_nearest_tag: None,
16947        git_remote_url: sub.git_remote_url.clone(),
16948        style_summary: None,
16949        cocomo: None,
16950        uloc: 0,
16951        dryness_pct: None,
16952        duplicate_groups: vec![],
16953        duplicates_excluded: 0,
16954    }
16955}
16956
16957#[must_use]
16958pub fn sanitize_project_label(raw: &str) -> String {
16959    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
16960    // where `Path` treats '\' as a literal character, not a separator.
16961    let candidate = raw
16962        .split(['/', '\\'])
16963        .rfind(|s| !s.is_empty())
16964        .unwrap_or("project");
16965
16966    let mut value = String::with_capacity(candidate.len());
16967    for ch in candidate.chars() {
16968        if ch.is_ascii_alphanumeric() {
16969            value.push(ch.to_ascii_lowercase());
16970        } else {
16971            value.push('-');
16972        }
16973    }
16974
16975    let compact = value.trim_matches('-').to_string();
16976    if compact.is_empty() {
16977        "project".to_string()
16978    } else {
16979        compact
16980    }
16981}
16982
16983/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
16984/// comparisons with non-canonicalized stored paths work correctly.
16985fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16986    let s = path.to_string_lossy();
16987    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16988        return PathBuf::from(format!(r"\\{rest}"));
16989    }
16990    if let Some(rest) = s.strip_prefix(r"\\?\") {
16991        return PathBuf::from(rest);
16992    }
16993    path
16994}
16995
16996/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
16997/// commit page URL for the most common hosting platforms.
16998fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16999    let base = if let Some(rest) = remote.strip_prefix("git@") {
17000        let (host, path) = rest.split_once(':')?;
17001        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17002    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17003        remote
17004            .trim_end_matches('/')
17005            .trim_end_matches(".git")
17006            .to_owned()
17007    } else {
17008        return None;
17009    };
17010    let base = base.trim_end_matches('/');
17011    // GitLab uses /-/commit/; everything else uses /commit/
17012    if base.contains("gitlab.com") || base.contains("gitlab.") {
17013        Some(format!("{base}/-/commit/{sha}"))
17014    } else if base.contains("bitbucket.org") {
17015        Some(format!("{base}/commits/{sha}"))
17016    } else {
17017        Some(format!("{base}/commit/{sha}"))
17018    }
17019}
17020
17021/// Convert a git remote URL (https or git@) + branch name into a browser-openable
17022/// branch page URL for the most common hosting platforms.
17023fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17024    let base = if let Some(rest) = remote.strip_prefix("git@") {
17025        let (host, path) = rest.split_once(':')?;
17026        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17027    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17028        remote
17029            .trim_end_matches('/')
17030            .trim_end_matches(".git")
17031            .to_owned()
17032    } else {
17033        return None;
17034    };
17035    let base = base.trim_end_matches('/');
17036    if base.contains("gitlab.com") || base.contains("gitlab.") {
17037        Some(format!("{base}/-/tree/{branch}"))
17038    } else {
17039        Some(format!("{base}/tree/{branch}"))
17040    }
17041}
17042
17043fn display_path(path: &Path) -> String {
17044    let s = path.to_string_lossy();
17045    // Strip Windows extended-length prefix for display only; the underlying
17046    // PathBuf remains unchanged so file operations are unaffected.
17047    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
17048    // \\?\C:\path           →  C:\path          (local drive)
17049    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17050        return format!(r"\\{rest}");
17051    }
17052    if let Some(rest) = s.strip_prefix(r"\\?\") {
17053        return rest.to_owned();
17054    }
17055    s.into_owned()
17056}
17057
17058fn sanitize_path_str(s: &str) -> String {
17059    // Forward-slash variants of the Windows extended-length prefix that appear
17060    // when paths stored as plain strings have been processed through some path
17061    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
17062    if let Some(rest) = s.strip_prefix("//?/UNC/") {
17063        return format!("//{rest}");
17064    }
17065    if let Some(rest) = s.strip_prefix("//?/") {
17066        return rest.to_owned();
17067    }
17068    display_path(Path::new(s))
17069}
17070
17071fn workspace_root() -> PathBuf {
17072    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
17073    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17074        let p = PathBuf::from(root);
17075        if p.is_dir() {
17076            return p;
17077        }
17078    }
17079
17080    // Current working directory — works for `cargo run` from the project root
17081    // and for scripts/run.sh which cds there first.
17082    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17083}
17084
17085/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
17086fn make_git_label(repo: &str, ref_name: &str) -> String {
17087    if repo.is_empty() || ref_name.is_empty() {
17088        return String::new();
17089    }
17090    let base = repo
17091        .trim_end_matches('/')
17092        .trim_end_matches(".git")
17093        .rsplit('/')
17094        .next()
17095        .unwrap_or("repo");
17096    let ref_safe: String = ref_name
17097        .chars()
17098        .map(|c| {
17099            if c.is_alphanumeric() || c == '-' || c == '.' {
17100                c
17101            } else {
17102                '_'
17103            }
17104        })
17105        .collect();
17106    format!("{base}_at_{ref_safe}_sloc")
17107}
17108
17109/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
17110fn desktop_dir() -> PathBuf {
17111    if let Ok(profile) = std::env::var("USERPROFILE") {
17112        let p = PathBuf::from(profile).join("Desktop");
17113        if p.exists() {
17114            return p;
17115        }
17116    }
17117    if let Ok(home) = std::env::var("HOME") {
17118        let p = PathBuf::from(home).join("Desktop");
17119        if p.exists() {
17120            return p;
17121        }
17122    }
17123    workspace_root().join("out").join("web")
17124}
17125
17126fn resolve_input_path(raw: &str) -> PathBuf {
17127    let trimmed = raw.trim();
17128    if trimmed.is_empty() {
17129        return workspace_root().join("samples").join("basic");
17130    }
17131
17132    let candidate = PathBuf::from(trimmed);
17133    let resolved = if candidate.is_absolute() {
17134        candidate
17135    } else {
17136        let rooted = workspace_root().join(&candidate);
17137        if rooted.exists() {
17138            rooted
17139        } else {
17140            workspace_root().join(candidate)
17141        }
17142    };
17143
17144    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
17145    // strip that prefix so stored paths and the displayed "Project path" are clean.
17146    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17147    PathBuf::from(display_path(&canonical))
17148}
17149
17150fn dir_size_bytes(path: &Path) -> u64 {
17151    let mut total = 0u64;
17152    if let Ok(rd) = fs::read_dir(path) {
17153        for entry in rd.filter_map(Result::ok) {
17154            let p = entry.path();
17155            if p.is_file() {
17156                if let Ok(meta) = p.metadata() {
17157                    total += meta.len();
17158                }
17159            } else if p.is_dir() {
17160                total += dir_size_bytes(&p);
17161            }
17162        }
17163    }
17164    total
17165}
17166
17167#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
17168fn format_dir_size(bytes: u64) -> String {
17169    if bytes >= 1_073_741_824 {
17170        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17171    } else if bytes >= 1_048_576 {
17172        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17173    } else if bytes >= 1_024 {
17174        format!("{:.0} KB", bytes as f64 / 1_024.0)
17175    } else {
17176        format!("{bytes} B")
17177    }
17178}
17179
17180fn render_submodule_chips(
17181    root: &Path,
17182    submodules: &[(String, std::path::PathBuf)],
17183    out: &mut String,
17184) {
17185    use std::fmt::Write as _;
17186    let count = submodules.len();
17187    out.push_str(r#"<div class="submodule-preview-strip">"#);
17188    write!(
17189        out,
17190        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>"#,
17191        if count == 1 { "" } else { "s" }
17192    )
17193    .ok();
17194    out.push_str(r#"<div class="submodule-preview-chips">"#);
17195    for (sub_name, sub_rel_path) in submodules {
17196        let sub_abs = root.join(sub_rel_path);
17197        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17198        let mut sub_stats = PreviewStats::default();
17199        let mut sub_rows: Vec<PreviewRow> = Vec::new();
17200        let mut sub_langs: Vec<&'static str> = Vec::new();
17201        let mut sub_budget = PreviewBudget {
17202            shown: 0,
17203            max_entries: 2000,
17204            max_depth: 9,
17205        };
17206        let mut sub_next_id = 1usize;
17207        let _ = collect_preview_rows(
17208            &sub_abs,
17209            &sub_abs,
17210            0,
17211            None,
17212            &mut sub_next_id,
17213            &mut sub_budget,
17214            &mut sub_stats,
17215            &mut sub_rows,
17216            &mut sub_langs,
17217            &[],
17218            &[],
17219        );
17220        let stats_json = format!(
17221            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17222            sub_stats.directories,
17223            sub_stats.files,
17224            sub_stats.supported,
17225            sub_stats.skipped,
17226            sub_stats.unsupported
17227        );
17228        write!(
17229            out,
17230            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>"#,
17231            escape_html(sub_name),
17232            escape_html(&sub_rel_path.to_string_lossy()),
17233            escape_html(&sub_size),
17234            escape_html(&stats_json),
17235            escape_html(sub_name),
17236            escape_html(&sub_size),
17237        )
17238        .ok();
17239    }
17240    out.push_str(
17241        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
17242    );
17243    out.push_str(r"</div>");
17244}
17245
17246/// Amber caution banner shown when the selected folder spans multiple independent
17247/// git repositories. Each repo is a one-click button that re-selects it as the
17248/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
17249fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17250    use std::fmt::Write as _;
17251    const MAX_LISTED: usize = 5;
17252    let total = layout.nested_repos.len();
17253
17254    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17255    if layout.root_is_repo {
17256        write!(
17257            out,
17258            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>",
17259            if total == 1 { "repository" } else { "repositories" }
17260        )
17261        .ok();
17262    } else {
17263        write!(
17264            out,
17265            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>"
17266        )
17267        .ok();
17268    }
17269
17270    out.push_str(r#"<div class="repo-pick-row">"#);
17271    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17272        let abs = root.join(rel);
17273        let abs_display = display_path(&abs);
17274        let label = rel.to_string_lossy().replace('\\', "/");
17275        write!(
17276            out,
17277            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17278            escape_html(&abs_display),
17279            escape_html(&label)
17280        )
17281        .ok();
17282    }
17283    if total > MAX_LISTED {
17284        write!(
17285            out,
17286            r#"<span class="repo-pick-more">and {} more</span>"#,
17287            total - MAX_LISTED
17288        )
17289        .ok();
17290    }
17291    out.push_str(r"</div>");
17292
17293    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17294    out.push_str(r"</div>");
17295}
17296
17297fn render_language_pills_row(languages: &[&str], out: &mut String) {
17298    use std::fmt::Write as _;
17299    if languages.is_empty() {
17300        out.push_str(
17301            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17302        );
17303        return;
17304    }
17305    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17306    for language in languages {
17307        if let Some(icon) = language_icon_file(language) {
17308            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();
17309        } else if let Some(svg) = language_inline_svg(language) {
17310            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();
17311        } else {
17312            write!(
17313                out,
17314                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17315                escape_html(&language.to_ascii_lowercase()),
17316                escape_html(language)
17317            )
17318            .ok();
17319        }
17320    }
17321}
17322
17323#[allow(clippy::too_many_lines)]
17324fn build_preview_html(
17325    root: &Path,
17326    include_patterns: &[String],
17327    exclude_patterns: &[String],
17328) -> Result<String> {
17329    if !root.exists() {
17330        return Ok(format!(
17331            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17332            escape_html(&display_path(root))
17333        ));
17334    }
17335
17336    let _selected = display_path(root);
17337    let mut stats = PreviewStats::default();
17338    let mut rows = Vec::new();
17339    let mut languages = Vec::new();
17340    let mut budget = PreviewBudget {
17341        shown: 0,
17342        max_entries: 600,
17343        max_depth: 9,
17344    };
17345    let mut next_row_id = 1usize;
17346
17347    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17348        || root.to_string_lossy().into_owned(),
17349        std::string::ToString::to_string,
17350    );
17351    let root_modified = root
17352        .metadata()
17353        .ok()
17354        .and_then(|meta| meta.modified().ok())
17355        .map_or_else(|| "-".to_string(), format_system_time);
17356
17357    rows.push(PreviewRow {
17358        row_id: 0,
17359        parent_row_id: None,
17360        depth: 0,
17361        name: format!("{root_name}/"),
17362        kind: PreviewKind::Dir,
17363        is_dir: true,
17364        language: None,
17365        modified: root_modified,
17366        type_label: "Directory".to_string(),
17367    });
17368    collect_preview_rows(
17369        root,
17370        root,
17371        0,
17372        Some(0),
17373        &mut next_row_id,
17374        &mut budget,
17375        &mut stats,
17376        &mut rows,
17377        &mut languages,
17378        include_patterns,
17379        exclude_patterns,
17380    )?;
17381
17382    let root_size = format_dir_size(dir_size_bytes(root));
17383
17384    let mut out = String::new();
17385    write!(
17386        out,
17387        r#"<div class="explorer-wrap" data-project-size="{}">"#,
17388        escape_html(&root_size)
17389    )
17390    .ok();
17391    out.push_str(r#"<div class="explorer-toolbar compact">"#);
17392    out.push_str(r#"<div class="explorer-title-group">"#);
17393    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17394    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17395    out.push_str(r"</div></div>");
17396
17397    out.push_str(r#"<div class="scope-stats">"#);
17398    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();
17399    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();
17400    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();
17401    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();
17402    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();
17403    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>"#);
17404    out.push_str(r"</div>");
17405
17406    let submodules = sloc_core::detect_submodules(root);
17407    if !submodules.is_empty() {
17408        render_submodule_chips(root, &submodules, &mut out);
17409    }
17410
17411    let repo_layout = sloc_core::detect_repository_layout(root);
17412    if repo_layout.has_multiple_repos() {
17413        render_multi_repo_warning(root, &repo_layout, &mut out);
17414    }
17415
17416    out.push_str(r#"<div class="scope-info-row">"#);
17417    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17418    render_language_pills_row(&languages, &mut out);
17419    out.push_str(r"</div></div>");
17420    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>"#);
17421    out.push_str(r"</div>");
17422
17423    out.push_str(r#"<div class="file-explorer-shell">"#);
17424    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>"#);
17425    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>"#);
17426    out.push_str(r#"<div class="file-explorer-tree">"#);
17427    for row in rows {
17428        let status_label = row.kind.label();
17429        let lang_attr = row.language.unwrap_or("");
17430        let toggle_html = if row.is_dir {
17431            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17432                .to_string()
17433        } else {
17434            r#"<span class="tree-bullet">•</span>"#.to_string()
17435        };
17436        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();
17437    }
17438    if budget.shown >= budget.max_entries {
17439        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>"#);
17440    }
17441    out.push_str(r"</div></div></div>");
17442
17443    Ok(out)
17444}
17445
17446#[derive(Default)]
17447struct PreviewStats {
17448    directories: usize,
17449    files: usize,
17450    supported: usize,
17451    skipped: usize,
17452    unsupported: usize,
17453}
17454
17455struct PreviewRow {
17456    row_id: usize,
17457    parent_row_id: Option<usize>,
17458    depth: usize,
17459    name: String,
17460    kind: PreviewKind,
17461    is_dir: bool,
17462    language: Option<&'static str>,
17463    modified: String,
17464    type_label: String,
17465}
17466
17467#[derive(Copy, Clone)]
17468enum PreviewKind {
17469    Dir,
17470    Supported,
17471    Skipped,
17472    Unsupported,
17473}
17474
17475impl PreviewKind {
17476    const fn filter_key(self) -> &'static str {
17477        match self {
17478            Self::Dir => "dir",
17479            Self::Supported => "supported",
17480            Self::Skipped => "skipped",
17481            Self::Unsupported => "unsupported",
17482        }
17483    }
17484
17485    const fn label(self) -> &'static str {
17486        match self {
17487            Self::Dir => "dir",
17488            Self::Supported => "supported",
17489            Self::Skipped => "skipped by policy",
17490            Self::Unsupported => "unsupported",
17491        }
17492    }
17493
17494    const fn badge_class(self) -> &'static str {
17495        match self {
17496            Self::Dir => "badge badge-dir",
17497            Self::Supported => "badge badge-scan",
17498            Self::Skipped => "badge badge-skip",
17499            Self::Unsupported => "badge badge-unsupported",
17500        }
17501    }
17502
17503    const fn node_class(self) -> &'static str {
17504        match self {
17505            Self::Dir => "tree-node-dir",
17506            Self::Supported => "tree-node-supported",
17507            Self::Skipped => "tree-node-skipped",
17508            Self::Unsupported => "tree-node-unsupported",
17509        }
17510    }
17511}
17512
17513struct PreviewBudget {
17514    shown: usize,
17515    max_entries: usize,
17516    max_depth: usize,
17517}
17518
17519/// Handle a single directory entry inside `collect_preview_rows`.
17520/// Returns `true` when the entry was handled (caller should `continue`).
17521#[allow(clippy::too_many_arguments)]
17522fn handle_preview_dir_entry(
17523    root: &Path,
17524    path: &Path,
17525    name: &str,
17526    modified: String,
17527    depth: usize,
17528    parent_row_id: Option<usize>,
17529    row_id: usize,
17530    next_row_id: &mut usize,
17531    budget: &mut PreviewBudget,
17532    stats: &mut PreviewStats,
17533    rows: &mut Vec<PreviewRow>,
17534    languages: &mut Vec<&'static str>,
17535    include_patterns: &[String],
17536    exclude_patterns: &[String],
17537) -> Result<()> {
17538    let relative = preview_relative_path(root, path);
17539    if should_skip_preview_directory(&relative, exclude_patterns) {
17540        return Ok(());
17541    }
17542    stats.directories += 1;
17543    rows.push(PreviewRow {
17544        row_id,
17545        parent_row_id,
17546        depth: depth + 1,
17547        name: format!("{name}/"),
17548        kind: PreviewKind::Dir,
17549        is_dir: true,
17550        language: None,
17551        modified,
17552        type_label: "Directory".to_string(),
17553    });
17554    budget.shown += 1;
17555    if !matches!(name, ".git" | "node_modules" | "target") {
17556        collect_preview_rows(
17557            root,
17558            path,
17559            depth + 1,
17560            Some(row_id),
17561            next_row_id,
17562            budget,
17563            stats,
17564            rows,
17565            languages,
17566            include_patterns,
17567            exclude_patterns,
17568        )?;
17569    }
17570    Ok(())
17571}
17572
17573/// Handle a single file entry inside `collect_preview_rows`.
17574#[allow(clippy::too_many_arguments)]
17575fn handle_preview_file_entry(
17576    root: &Path,
17577    path: &Path,
17578    name: &str,
17579    modified: String,
17580    depth: usize,
17581    parent_row_id: Option<usize>,
17582    row_id: usize,
17583    budget: &mut PreviewBudget,
17584    stats: &mut PreviewStats,
17585    rows: &mut Vec<PreviewRow>,
17586    languages: &mut Vec<&'static str>,
17587    include_patterns: &[String],
17588    exclude_patterns: &[String],
17589) {
17590    let relative = preview_relative_path(root, path);
17591    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17592        return;
17593    }
17594    stats.files += 1;
17595    let kind = classify_preview_file(name);
17596    match kind {
17597        PreviewKind::Supported => stats.supported += 1,
17598        PreviewKind::Skipped => stats.skipped += 1,
17599        PreviewKind::Unsupported => stats.unsupported += 1,
17600        PreviewKind::Dir => {}
17601    }
17602    let language = detect_language_name(name);
17603    if let Some(lang) = language {
17604        if !languages.contains(&lang) {
17605            languages.push(lang);
17606        }
17607    }
17608    rows.push(PreviewRow {
17609        row_id,
17610        parent_row_id,
17611        depth: depth + 1,
17612        name: name.to_owned(),
17613        kind,
17614        is_dir: false,
17615        language,
17616        modified,
17617        type_label: preview_type_label(name, language, kind),
17618    });
17619    budget.shown += 1;
17620}
17621
17622#[allow(clippy::too_many_arguments)]
17623#[allow(clippy::too_many_lines)]
17624fn collect_preview_rows(
17625    root: &Path,
17626    dir: &Path,
17627    depth: usize,
17628    parent_row_id: Option<usize>,
17629    next_row_id: &mut usize,
17630    budget: &mut PreviewBudget,
17631    stats: &mut PreviewStats,
17632    rows: &mut Vec<PreviewRow>,
17633    languages: &mut Vec<&'static str>,
17634    include_patterns: &[String],
17635    exclude_patterns: &[String],
17636) -> Result<()> {
17637    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17638        return Ok(());
17639    }
17640
17641    let mut entries = fs::read_dir(dir)
17642        .with_context(|| format!("failed to read directory {}", dir.display()))?
17643        .filter_map(std::result::Result::ok)
17644        .collect::<Vec<_>>();
17645    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17646
17647    for entry in entries {
17648        if budget.shown >= budget.max_entries {
17649            break;
17650        }
17651
17652        let path = entry.path();
17653        let name = entry.file_name().to_string_lossy().into_owned();
17654        let Ok(metadata) = entry.metadata() else {
17655            continue;
17656        };
17657        let row_id = *next_row_id;
17658        *next_row_id += 1;
17659        let modified = metadata
17660            .modified()
17661            .ok()
17662            .map_or_else(|| "-".to_string(), format_system_time);
17663
17664        if metadata.is_dir() {
17665            handle_preview_dir_entry(
17666                root,
17667                &path,
17668                &name,
17669                modified,
17670                depth,
17671                parent_row_id,
17672                row_id,
17673                next_row_id,
17674                budget,
17675                stats,
17676                rows,
17677                languages,
17678                include_patterns,
17679                exclude_patterns,
17680            )?;
17681            continue;
17682        }
17683
17684        if metadata.is_file() {
17685            handle_preview_file_entry(
17686                root,
17687                &path,
17688                &name,
17689                modified,
17690                depth,
17691                parent_row_id,
17692                row_id,
17693                budget,
17694                stats,
17695                rows,
17696                languages,
17697                include_patterns,
17698                exclude_patterns,
17699            );
17700        }
17701    }
17702
17703    Ok(())
17704}
17705
17706fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17707    if let Some(language) = language {
17708        return format!("{language} source");
17709    }
17710    let lower = name.to_ascii_lowercase();
17711    let ext = Path::new(&lower)
17712        .extension()
17713        .and_then(|e| e.to_str())
17714        .unwrap_or("");
17715    match kind {
17716        PreviewKind::Skipped => {
17717            if lower.ends_with(".min.js") {
17718                "Minified asset".to_string()
17719            } else if [
17720                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17721            ]
17722            .contains(&ext)
17723            {
17724                "Binary or archive".to_string()
17725            } else {
17726                "Skipped file".to_string()
17727            }
17728        }
17729        PreviewKind::Unsupported => {
17730            if ext.is_empty() {
17731                "Unsupported file".to_string()
17732            } else {
17733                format!("{} file", ext.to_ascii_uppercase())
17734            }
17735        }
17736        PreviewKind::Supported => "Supported source".to_string(),
17737        PreviewKind::Dir => "Directory".to_string(),
17738    }
17739}
17740
17741fn format_system_time(time: SystemTime) -> String {
17742    #[allow(clippy::cast_possible_wrap)]
17743    let secs = match time.duration_since(UNIX_EPOCH) {
17744        Ok(duration) => duration.as_secs() as i64,
17745        Err(_) => return "-".to_string(),
17746    };
17747    let days = secs.div_euclid(86_400);
17748    let secs_of_day = secs.rem_euclid(86_400);
17749    let (year, month, day) = civil_from_days(days);
17750    let hour = secs_of_day / 3_600;
17751    let minute = (secs_of_day % 3_600) / 60;
17752    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17753}
17754
17755#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17756fn civil_from_days(days: i64) -> (i32, u32, u32) {
17757    let z = days + 719_468;
17758    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17759    let doe = z - era * 146_097;
17760    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17761    let y = yoe + era * 400;
17762    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17763    let mp = (5 * doy + 2) / 153;
17764    let d = doy - (153 * mp + 2) / 5 + 1;
17765    let m = mp + if mp < 10 { 3 } else { -9 };
17766    let year = y + i64::from(m <= 2);
17767    (year as i32, m as u32, d as u32)
17768}
17769
17770// The input is already lowercased via `to_ascii_lowercase()` before calling
17771// `ends_with`, so the comparisons are inherently case-insensitive.
17772#[allow(clippy::case_sensitive_file_extension_comparisons)]
17773fn detect_language_name(name: &str) -> Option<&'static str> {
17774    let lower = name.to_ascii_lowercase();
17775    if lower.ends_with(".c") || lower.ends_with(".h") {
17776        Some("C")
17777    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17778        .iter()
17779        .any(|s| lower.ends_with(s))
17780    {
17781        Some("C++")
17782    } else if lower.ends_with(".cs") {
17783        Some("C#")
17784    } else if lower.ends_with(".py") {
17785        Some("Python")
17786    } else if lower.ends_with(".sh") {
17787        Some("Shell")
17788    } else if [".ps1", ".psm1", ".psd1"]
17789        .iter()
17790        .any(|s| lower.ends_with(s))
17791    {
17792        Some("PowerShell")
17793    } else {
17794        None
17795    }
17796}
17797
17798fn language_icon_file(language: &str) -> Option<&'static str> {
17799    match language {
17800        "C" => Some("c.png"),
17801        "C++" => Some("cpp.png"),
17802        "C#" => Some("c-sharp.png"),
17803        "Python" => Some("python.png"),
17804        "Shell" => Some("shell.png"),
17805        "PowerShell" => Some("powershell.png"),
17806        "JavaScript" => Some("java-script.png"),
17807        "HTML" => Some("html-5.png"),
17808        "Java" => Some("java.png"),
17809        "Visual Basic" => Some("visual-basic.png"),
17810        "Assembly" => Some("asm.png"),
17811        "Go" => Some("go.png"),
17812        "R" => Some("r.png"),
17813        "XML" => Some("xml.png"),
17814        "Groovy" => Some("groovy.png"),
17815        "Dockerfile" => Some("docker.png"),
17816        "Makefile" => Some("makefile.svg"),
17817        "Perl" => Some("perl.svg"),
17818        _ => None,
17819    }
17820}
17821
17822// Inline SVG badges for languages that have no PNG icon in images/icons/.
17823// Using inline SVG keeps the web UI fully self-contained — no extra files
17824// needed on disk, no 404s on air-gapped deployments.
17825// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
17826fn language_inline_svg(language: &str) -> Option<&'static str> {
17827    match language {
17828        "Rust" => Some(
17829            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>"##,
17830        ),
17831        "TypeScript" => Some(
17832            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>"##,
17833        ),
17834        _ => None,
17835    }
17836}
17837
17838// The input is already lowercased via `to_ascii_lowercase()` before the
17839// `ends_with` calls, so these comparisons are inherently case-insensitive.
17840#[allow(clippy::case_sensitive_file_extension_comparisons)]
17841fn classify_preview_file(name: &str) -> PreviewKind {
17842    let lower = name.to_ascii_lowercase();
17843
17844    let scannable = [
17845        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17846        ".psm1", ".psd1",
17847    ]
17848    .iter()
17849    .any(|suffix| lower.ends_with(suffix));
17850
17851    if scannable {
17852        PreviewKind::Supported
17853    } else if lower.ends_with(".min.js")
17854        || lower.ends_with(".lock")
17855        || lower.ends_with(".png")
17856        || lower.ends_with(".jpg")
17857        || lower.ends_with(".jpeg")
17858        || lower.ends_with(".gif")
17859        || lower.ends_with(".zip")
17860        || lower.ends_with(".pdf")
17861        || lower.ends_with(".pyc")
17862        || lower.ends_with(".xz")
17863        || lower.ends_with(".tar")
17864        || lower.ends_with(".gz")
17865    {
17866        PreviewKind::Skipped
17867    } else {
17868        PreviewKind::Unsupported
17869    }
17870}
17871
17872fn preview_relative_path(root: &Path, path: &Path) -> String {
17873    path.strip_prefix(root)
17874        .ok()
17875        .unwrap_or(path)
17876        .to_string_lossy()
17877        .replace('\\', "/")
17878        .trim_matches('/')
17879        .to_string()
17880}
17881
17882fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17883    if relative.is_empty() {
17884        return false;
17885    }
17886
17887    exclude_patterns.iter().any(|pattern| {
17888        wildcard_match(pattern, relative)
17889            || wildcard_match(pattern, &format!("{relative}/"))
17890            || wildcard_match(pattern, &format!("{relative}/placeholder"))
17891    })
17892}
17893
17894fn should_include_preview_file(
17895    relative: &str,
17896    include_patterns: &[String],
17897    exclude_patterns: &[String],
17898) -> bool {
17899    if relative.is_empty() {
17900        return true;
17901    }
17902
17903    let included = include_patterns.is_empty()
17904        || include_patterns
17905            .iter()
17906            .any(|pattern| wildcard_match(pattern, relative));
17907    let excluded = exclude_patterns
17908        .iter()
17909        .any(|pattern| wildcard_match(pattern, relative));
17910
17911    included && !excluded
17912}
17913
17914fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17915    let pattern = pattern.trim().replace('\\', "/");
17916    let candidate = candidate.trim().replace('\\', "/");
17917    let p = pattern.as_bytes();
17918    let c = candidate.as_bytes();
17919    let mut pi = 0usize;
17920    let mut ci = 0usize;
17921    let mut star: Option<usize> = None;
17922    let mut star_match = 0usize;
17923
17924    while ci < c.len() {
17925        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17926            pi += 1;
17927            ci += 1;
17928        } else if pi < p.len() && p[pi] == b'*' {
17929            while pi < p.len() && p[pi] == b'*' {
17930                pi += 1;
17931            }
17932            star = Some(pi);
17933            star_match = ci;
17934        } else if let Some(star_pi) = star {
17935            star_match += 1;
17936            ci = star_match;
17937            pi = star_pi;
17938        } else {
17939            return false;
17940        }
17941    }
17942
17943    while pi < p.len() && p[pi] == b'*' {
17944        pi += 1;
17945    }
17946
17947    pi == p.len()
17948}
17949
17950fn escape_html(value: &str) -> String {
17951    value
17952        .replace('&', "&amp;")
17953        .replace('<', "&lt;")
17954        .replace('>', "&gt;")
17955        .replace('"', "&quot;")
17956        .replace('\'', "&#39;")
17957}
17958
17959#[derive(Clone)]
17960struct SubmoduleRow {
17961    name: String,
17962    relative_path: String,
17963    files_analyzed: u64,
17964    code_lines: u64,
17965    comment_lines: u64,
17966    blank_lines: u64,
17967    total_physical_lines: u64,
17968    html_url: Option<String>,
17969}
17970
17971#[derive(Template)]
17972#[template(
17973    source = r##"
17974<!doctype html>
17975<html lang="en">
17976<head>
17977  <meta charset="utf-8">
17978  <title>OxideSLOC | tmp-sloc</title>
17979  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17980  <style nonce="{{ csp_nonce }}">
17981    :root {
17982      --bg: #efe9e2;
17983      --surface: #fcfaf7;
17984      --surface-2: #f7f0e8;
17985      --surface-3: #efe3d5;
17986      --line: #dfcfbf;
17987      --line-strong: #cfb29c;
17988      --text: #2f241c;
17989      --muted: #6f6257;
17990      --muted-2: #917f71;
17991      --nav: #b85d33;
17992      --nav-2: #7a371b;
17993      --accent: #2563eb;
17994      --accent-2: #1d4ed8;
17995      --oxide: #b85d33;
17996      --oxide-2: #8f4220;
17997      --success-bg: #eaf9ee;
17998      --success-text: #1c8746;
17999      --warn-bg: #fff2d8;
18000      --warn-text: #926000;
18001      --danger-bg: #fdeaea;
18002      --danger-text: #b33b3b;
18003      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18004      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18005      --radius: 14px;
18006    }
18007
18008    body.dark-theme {
18009      --bg: #1b1511;
18010      --surface: #261c17;
18011      --surface-2: #2d221d;
18012      --surface-3: #372922;
18013      --line: #524238;
18014      --line-strong: #6c5649;
18015      --text: #f5ece6;
18016      --muted: #c7b7aa;
18017      --muted-2: #aa9485;
18018      --nav: #b85d33;
18019      --nav-2: #7a371b;
18020      --accent: #6f9bff;
18021      --accent-2: #4a78ee;
18022      --oxide: #d37a4c;
18023      --oxide-2: #b35428;
18024      --success-bg: #163927;
18025      --success-text: #8fe2a8;
18026      --warn-bg: #3c2d11;
18027      --warn-text: #f3cb75;
18028      --danger-bg: #3d1f1f;
18029      --danger-text: #ff9f9f;
18030      --shadow: 0 14px 28px rgba(0,0,0,0.28);
18031      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18032    }
18033
18034    * { box-sizing: border-box; }
18035    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); }
18036    html { overflow-y: scroll; }
18037    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18038    .top-nav, .page, .loading { position: relative; z-index: 2; }
18039    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18040    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18041    .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); }
18042    .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; }
18043    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18044    .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)); }
18045    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18046    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18047    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18048    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18049    .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; }
18050    .nav-project-pill.visible { display:inline-flex; }
18051    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18052    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18053    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18054    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18055    @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; } }
18056    .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; }
18057    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18058    .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; }
18059    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18060    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18061    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18062    .theme-toggle .icon-sun { display:none; }
18063    body.dark-theme .theme-toggle .icon-sun { display:block; }
18064    body.dark-theme .theme-toggle .icon-moon { display:none; }
18065    .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;}
18066    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18067    .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);}
18068    .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;}
18069    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18070    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18071    .settings-modal-body{padding:14px 16px 16px;}
18072    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18073    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18074    .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;}
18075    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18076    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18077    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18078    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18079    .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;}
18080    .tz-select:focus{border-color:var(--oxide);}
18081    .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; }
18082    .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;}
18083    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18084    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18085    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18086    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18087    .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; }
18088    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18089    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18090    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18091    .wb-stats-header { padding: 10px 24px 0; }
18092    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18093    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18094    .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; }
18095    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18096    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18097    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18098    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18099    .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; }
18100    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18101    .ws-stat-analyzers { position: relative; }
18102    .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; }
18103    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18104    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18105    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18106    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18107    .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; }
18108    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18109    .ws-divider { display: none; }
18110    .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%; }
18111    .ws-path-link:hover { color:var(--oxide); }
18112    body.dark-theme .ws-path-link { color:var(--oxide); }
18113    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18114    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18115    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18116    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18117    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18118    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18119    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18120    .ws-mini-box-lg { flex:2 1 0; }
18121    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18122    .ws-mini-box-br { flex:1.5 1 0; }
18123    .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; }
18124    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18125    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18126    #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; }
18127    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18128    .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; }
18129    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18130    .git-source-banner strong { font-weight:800; color:var(--text); }
18131    .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; }
18132    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18133    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18134    .git-source-banner a:hover { text-decoration:underline; }
18135    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18136    .path-scope-sep { background:var(--line); margin:4px 14px; }
18137    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18138    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18139    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18140    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18141    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18142    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18143    .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; }
18144    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18145    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18146    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18147    .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; }
18148    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18149    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18150    [data-wb-tip] { cursor:help; }
18151    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18152    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18153    .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; }
18154    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18155    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18156    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18157    .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; }
18158    .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); }
18159    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18160    .side-info-card { padding: 18px; }
18161    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18162    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18163    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18164    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18165    .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); }
18166    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18167    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18168    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18169    .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; }
18170    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18171    .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; }
18172    .side-stack::-webkit-scrollbar { display: none; }
18173    .step-nav { padding: 20px 16px; }
18174    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18175    .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; }
18176    .step-button:hover { background: var(--surface-2); }
18177    .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); }
18178    .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; }
18179    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18180    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18181    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18182    .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); }
18183    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18184    .step-nav-sum-row:last-child { border-bottom:none; }
18185    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18186    .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; }
18187    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18188    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18189    .quick-scan-section { padding: 10px 4px 14px; }
18190    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18191    .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; }
18192    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18193    .quick-scan-btn:active { transform:translateY(0); }
18194    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18195    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18196    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18197    @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);} }
18198    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18199    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18200    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18201    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18202    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18203    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18204    .step-button.done .step-check { opacity:1; }
18205    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18206    .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; }
18207    .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; }
18208    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18209    .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; }
18210    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18211    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18212    .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; }
18213    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18214    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18215    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18216    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18217    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18218    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18219    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18220    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18221    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18222    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18223    .card-body { padding: 22px; }
18224    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18225    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18226    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18227    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18228    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18229    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18230    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18231    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18232    .field { min-width:0; }
18233    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18234    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; }
18235    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); }
18236    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18237    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); }
18238    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18239    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18240    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18241    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18242    .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; }
18243    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18244    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18245    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18246    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18247    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18248    .input-group.compact { grid-template-columns: 1fr auto auto; }
18249    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18250    .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)); }
18251    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18252    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18253    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18254    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18255    .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; }
18256    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18257    .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; }
18258    .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); }
18259    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18260    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18261    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18262    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18263    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18264    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18265    @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; } }
18266    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18267    button.secondary { background: var(--surface); }
18268    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18269    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18270    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18271    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18272    .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); }
18273    .section + .wizard-actions { border-top: none; padding-top: 0; }
18274    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18275    .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; }
18276    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18277    .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; }
18278    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18279    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18280    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18281    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18282    .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); }
18283    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18284    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18285    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18286    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18287    .field-help-grid.coupled-help { margin-top: 12px; }
18288    .field-help-grid.preset-grid { align-items: start; }
18289    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18290    .preset-inline-row .field { margin: 0; }
18291    .preset-inline-row .explainer-card { margin: 0; }
18292    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18293    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18294    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18295    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18296    .preset-kv-row > :last-child { flex:1; min-width:0; }
18297    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18298    .output-field-row .field { margin: 0; }
18299    .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; }
18300    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18301    .step3-subtitle { margin-bottom: 10px; max-width: none; }
18302    .counting-intro { margin-bottom: 8px; max-width: none; }
18303    .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; }
18304    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18305    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18306    .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; }
18307    .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; }
18308    .section-spacer-top { margin-top: 28px; }
18309    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18310    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18311    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18312    .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); }
18313    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18314    .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; }
18315    .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; }
18316    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18317    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18318    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18319    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18320    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18321    .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; }
18322    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18323    .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); }
18324    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; }
18325    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; }
18326    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18327    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18328    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18329    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18330    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18331    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18332    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18333    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18334    .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); }
18335    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18336    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18337    .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; }
18338    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18339    .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; }
18340    .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; }
18341    .always-tracked-tip-body { flex:1; min-width:0; }
18342    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18343    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18344    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18345    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18346    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18347    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18348    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18349    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18350    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18351    .advanced-rule-description strong { color: var(--text); }
18352    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18353    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18354    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18355    .review-link:hover { text-decoration: underline; }
18356    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18357    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18358    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18359    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18360    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18361    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18362    .review-card ul { padding-left: 18px; margin: 0; }
18363    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18364    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18365    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18366    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18367    .review-card { min-height: 0; }
18368    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18369    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18370    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18371    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18372    .lang-overflow-chip { position:relative; cursor:default; }
18373    .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; }
18374    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18375    .git-inline-row { align-items:start; }
18376    .mixed-line-card { display:flex; flex-direction:column; }
18377    .preset-inline-row .toggle-card { justify-content: center; }
18378        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18379    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18380    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18381    .explorer-title { font-size: 18px; font-weight: 850; }
18382    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18383    .explorer-subtitle.wide { max-width: none; }
18384    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18385    .better-spacing { align-items:flex-start; justify-content:flex-end; }
18386    .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; }
18387    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18388    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18389    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18390    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18391    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18392    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18393    .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; }
18394    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18395    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18396    .scope-stat-button.supported { background: var(--success-bg); }
18397    .scope-stat-button.skipped { background: var(--warn-bg); }
18398    .scope-stat-button.unsupported { background: var(--danger-bg); }
18399    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18400    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18401    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18402    [data-tooltip] { position: relative; }
18403    [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); }
18404    [data-tooltip]:hover::after { display: block; }
18405    .scope-stat-button[data-tooltip] { cursor: pointer; }
18406    .badge[data-tooltip] { cursor: help; }
18407    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18408    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18409    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18410    .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; }
18411    .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; }
18412    code { display:inline-block; margin-top:0; padding:2px 7px; }
18413    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18414    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18415    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18416    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18417    .language-pill.muted-pill { color: var(--muted); }
18418    button.language-pill { appearance:none; cursor:pointer; }
18419    .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); }
18420    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18421    .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; }
18422    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18423    .file-explorer-search-row { margin-left: auto; }
18424    .explorer-filter-select { min-width: 170px; width: 170px; }
18425    .explorer-search { min-width: 300px; width: 300px; }
18426    .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); }
18427    .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; }
18428    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18429    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18430    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18431    .file-explorer-tree { max-height: 640px; overflow:auto; }
18432    .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); }
18433    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18434    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18435    .tree-row.hidden-by-filter { display:none !important; }
18436    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18437    .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; }
18438    .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; }
18439    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18440    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18441    .tree-node { display:inline-flex; align-items:center; min-width:0; }
18442    .tree-node-dir { color: var(--text); font-weight: 800; }
18443    .tree-node-supported { color: var(--success-text); }
18444    .tree-node-skipped { color: var(--warn-text); }
18445    .tree-node-unsupported { color: var(--danger-text); }
18446    .tree-node-more { color: var(--muted-2); font-style: italic; }
18447    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18448    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18449    .tree-status-cell { display:flex; justify-content:flex-start; }
18450    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18451    .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; }
18452    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18453    .preview-warning p { margin: 0 0 10px; }
18454    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18455    .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; }
18456    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18457    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18458    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18459    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18460    .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; }
18461    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18462    .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; }
18463    @keyframes prevSpin { to { transform:rotate(360deg); } }
18464    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18465    .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; }
18466    .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; }
18467    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18468    .preview-gate-info svg { width:16px; height:16px; }
18469    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18470    @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); } }
18471    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18472    .preview-loading-text { flex:1; min-width:0; }
18473    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18474    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18475    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18476    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18477    .cov-scan-idle { display:none; }
18478    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18479    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18480    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18481    .cov-scan-title { font-weight:600; font-size:12.5px; }
18482    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18483    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18484    .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; }
18485    .cov-scan-use:hover { opacity:.75; }
18486    .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; }
18487    .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; }
18488    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18489    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18490    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18491    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18492    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18493    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18494    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18495    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18496    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18497    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18498    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18499    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18500    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18501    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18502    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18503    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18504    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18505    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18506    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18507    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18508    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18509    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18510    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18511    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18512    .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); }
18513    .loading.active { display:flex; }
18514    /* Lock page scroll while the analysis modal is open so the removed scrollbar
18515       gutter doesn't pull the centered card slightly left of true center. */
18516    body.modal-open { overflow: hidden; }
18517    .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; }
18518    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18519    .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; }
18520    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18521    .loading-card > * { position:relative; z-index:1; }
18522    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18523    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%); }
18524    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18525    .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; }
18526    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18527    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18528    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18529    .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; }
18530    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18531    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18532    .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; }
18533    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18534    .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; }
18535    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18536    .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; }
18537    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18538    .lc-step.done { color:var(--muted);opacity:0.55; }
18539    .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; }
18540    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18541    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18542    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18543    .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; }
18544    .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; }
18545    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18546    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18547    .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; }
18548    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18549    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18550    .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; }
18551    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18552    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18553    .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; }
18554    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18555    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18556    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18557    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18558    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18559    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18560    .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; }
18561    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18562    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18563    .hidden { display:none !important; }
18564    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18565    .site-footer a{color:var(--muted);}
18566    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18567    @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; } }
18568    .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;}
18569    @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));}}
18570    .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;}
18571    .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; }
18572    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18573    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18574    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18575    .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; }
18576    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18577    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18578    .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; }
18579    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18580    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18581    .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; }
18582    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18583    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18584    .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; }
18585    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18586    .info-icon-btn:hover { color:var(--text); }
18587    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); }
18588    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18589    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18590    .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;}
18591    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18592    .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;}
18593    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18594    #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);}
18595    #offline-file-banner.show{display:flex;}
18596    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18597    #offline-file-banner .ofb-text{flex:1;}
18598    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18599    #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;}
18600    #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;}
18601    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18602    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18603    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18604    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18605    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18606    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18607    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18608  </style>
18609</head>
18610<body id="page-top">
18611  <div id="offline-file-banner" role="alert">
18612    <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>
18613    <span class="ofb-text">
18614      Charts, images, and navigation require the oxide-sloc server.
18615      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18616      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18617      The metric tables below are fully readable without the server.
18618    </span>
18619    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18620  </div>
18621  <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>
18622  <div class="background-watermarks" aria-hidden="true">
18623    <img src="/images/logo/logo-text.png" alt="" />
18624    <img src="/images/logo/logo-text.png" alt="" />
18625    <img src="/images/logo/logo-text.png" alt="" />
18626    <img src="/images/logo/logo-text.png" alt="" />
18627    <img src="/images/logo/logo-text.png" alt="" />
18628    <img src="/images/logo/logo-text.png" alt="" />
18629    <img src="/images/logo/logo-text.png" alt="" />
18630    <img src="/images/logo/logo-text.png" alt="" />
18631    <img src="/images/logo/logo-text.png" alt="" />
18632    <img src="/images/logo/logo-text.png" alt="" />
18633    <img src="/images/logo/logo-text.png" alt="" />
18634    <img src="/images/logo/logo-text.png" alt="" />
18635    <img src="/images/logo/logo-text.png" alt="" />
18636    <img src="/images/logo/logo-text.png" alt="" />
18637  </div>
18638  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18639  <div class="top-nav">
18640    <div class="top-nav-inner">
18641      <a class="brand" href="/">
18642        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18643        <div class="brand-copy">
18644          <div class="brand-title">OxideSLOC</div>
18645          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18646        </div>
18647      </a>
18648      <div class="nav-project-slot">
18649        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18650          <span class="nav-project-label">Project</span>
18651          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18652        </div>
18653      </div>
18654      <div class="nav-status">
18655        <a class="nav-pill" href="/">Home</a>
18656        <div class="nav-dropdown">
18657          <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>
18658          <div class="nav-dropdown-menu">
18659            <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>
18660          </div>
18661        </div>
18662        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18663        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18664        <div class="nav-dropdown">
18665          <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>
18666          <div class="nav-dropdown-menu">
18667            <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>
18668          </div>
18669        </div>
18670        <div class="server-status-wrap" id="server-status-wrap">
18671          <div class="nav-pill server-online-pill" id="server-status-pill">
18672            <span class="status-dot" id="status-dot"></span>
18673            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18674            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18675          </div>
18676          <div class="server-status-tip">
18677            {% if server_mode %}
18678            OxideSLOC is running in server mode — accessible on your LAN.
18679            {% else %}
18680            OxideSLOC is running locally — only accessible from this machine.
18681            {% endif %}
18682            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18683          </div>
18684        </div>
18685        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18686          <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>
18687        </button>
18688        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18689          <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>
18690          <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>
18691        </button>
18692      </div>
18693    </div>
18694  </div>
18695
18696  <div class="loading" id="loading">
18697    <div class="loading-card" id="loading-card">
18698      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18699      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18700      <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>
18701      <div class="lc-steps" id="lc-steps">
18702        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18703        <div class="lc-step-arrow">›</div>
18704        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18705        <div class="lc-step-arrow">›</div>
18706        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18707        <div class="lc-step-arrow">›</div>
18708        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18709      </div>
18710      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18711      <div class="lc-metrics" id="lc-metrics">
18712        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18713        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18714        <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>
18715        <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>
18716      </div>
18717      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18718      <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>
18719      <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>
18720      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18721      <div class="lc-actions hidden" id="lc-actions">
18722        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18723        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18724      </div>
18725      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18726        <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>
18727        Cancel scan
18728      </button>
18729    </div>
18730  </div>
18731
18732  <div class="page">
18733    <div class="workbench-strip">
18734      <div class="workbench-box wb-stats">
18735        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18736          <span class="wb-stats-title">Analysis session</span>
18737        </div>
18738        <div class="ws-left">
18739          <div class="ws-stat ws-stat-analyzers">
18740            <span class="ws-label">Analyzers</span>
18741            <span class="ws-value">
18742              <span class="ws-badge">60 languages</span>
18743            </span>
18744            <div class="ws-lang-tooltip">
18745              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18746              <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>
18747              <div class="ws-lang-grid">
18748                <span class="ws-lang-item">Assembly</span>
18749                <span class="ws-lang-item">C</span>
18750                <span class="ws-lang-item">C++</span>
18751                <span class="ws-lang-item">C#</span>
18752                <span class="ws-lang-item">Clojure</span>
18753                <span class="ws-lang-item">CSS</span>
18754                <span class="ws-lang-item">Dart</span>
18755                <span class="ws-lang-item">Dockerfile</span>
18756                <span class="ws-lang-item">Elixir</span>
18757                <span class="ws-lang-item">Erlang</span>
18758                <span class="ws-lang-item">F#</span>
18759                <span class="ws-lang-item">Go</span>
18760                <span class="ws-lang-item">Groovy</span>
18761                <span class="ws-lang-item">Haskell</span>
18762                <span class="ws-lang-item">HTML</span>
18763                <span class="ws-lang-item">Java</span>
18764                <span class="ws-lang-item">JavaScript</span>
18765                <span class="ws-lang-item">Julia</span>
18766                <span class="ws-lang-item">Kotlin</span>
18767                <span class="ws-lang-item">Lua</span>
18768                <span class="ws-lang-item">Makefile</span>
18769                <span class="ws-lang-item">Nim</span>
18770                <span class="ws-lang-item">Obj-C</span>
18771                <span class="ws-lang-item">OCaml</span>
18772                <span class="ws-lang-item">Perl</span>
18773                <span class="ws-lang-item">PHP</span>
18774                <span class="ws-lang-item">PowerShell</span>
18775                <span class="ws-lang-item">Python</span>
18776                <span class="ws-lang-item">R</span>
18777                <span class="ws-lang-item">Ruby</span>
18778                <span class="ws-lang-item">Rust</span>
18779                <span class="ws-lang-item">Scala</span>
18780                <span class="ws-lang-item">SCSS</span>
18781                <span class="ws-lang-item">Shell</span>
18782                <span class="ws-lang-item">SQL</span>
18783                <span class="ws-lang-item">Svelte</span>
18784                <span class="ws-lang-item">Swift</span>
18785                <span class="ws-lang-item">TypeScript</span>
18786                <span class="ws-lang-item">Vue</span>
18787                <span class="ws-lang-item">XML</span>
18788                <span class="ws-lang-item">Zig</span>
18789                <span class="ws-lang-item">Solidity</span>
18790                <span class="ws-lang-item">Protobuf</span>
18791                <span class="ws-lang-item">HCL</span>
18792                <span class="ws-lang-item">GraphQL</span>
18793                <span class="ws-lang-item">Ada</span>
18794                <span class="ws-lang-item">VHDL</span>
18795                <span class="ws-lang-item">Verilog</span>
18796                <span class="ws-lang-item">Tcl</span>
18797                <span class="ws-lang-item">Pascal</span>
18798                <span class="ws-lang-item">Visual Basic</span>
18799                <span class="ws-lang-item">Lisp</span>
18800                <span class="ws-lang-item">Fortran</span>
18801                <span class="ws-lang-item">Nix</span>
18802                <span class="ws-lang-item">Crystal</span>
18803                <span class="ws-lang-item">D</span>
18804                <span class="ws-lang-item">GLSL</span>
18805                <span class="ws-lang-item">CMake</span>
18806                <span class="ws-lang-item">Elm</span>
18807                <span class="ws-lang-item">Awk</span>
18808              </div>
18809            </div>
18810          </div>
18811          <div class="ws-divider"></div>
18812          <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>
18813          <div class="ws-divider"></div>
18814          <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.">
18815            <span class="ws-label">Output</span>
18816            <span class="ws-value">
18817              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18818                <span id="ws-output-root">project/sloc</span>
18819              </button>
18820            </span>
18821          </div>
18822        </div>
18823      </div>
18824      <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.">
18825        <div class="ws-history-label">Scan history</div>
18826        <div class="ws-history-inner">
18827          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18828            <div class="ws-mini-label">Scans</div>
18829            <div class="ws-mini-value" id="ws-scan-count">—</div>
18830          </div>
18831          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18832            <div class="ws-mini-label">Last Scan</div>
18833            <div class="ws-mini-value" id="ws-last-scan">—</div>
18834          </div>
18835          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18836            <div class="ws-mini-label">Branch</div>
18837            <div class="ws-mini-value" id="ws-branch">—</div>
18838          </div>
18839        </div>
18840      </div>
18841    </div>
18842
18843    <div class="layout">
18844      <aside class="side-stack">
18845        <section class="step-nav">
18846        <h3>Guided scan setup</h3>
18847        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18848          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18849          Top of page
18850        </a>
18851        <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>
18852        <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>
18853        <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>
18854        <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>
18855
18856        <div class="step-steps-divider"></div>
18857
18858        <div class="step-nav-info" id="step-nav-info">
18859          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18860          <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>
18861        </div>
18862
18863        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18864          <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>
18865          <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>
18866          <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>
18867        </div>
18868
18869        <div class="quick-scan-divider"></div>
18870        <div class="quick-scan-section">
18871          <div class="quick-scan-label">No customization needed?</div>
18872          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18873            <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>
18874            Quick Scan
18875          </button>
18876          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18877        </div>
18878
18879        <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>
18880        <div class="sidebar-scroll-divider"></div>
18881        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18882          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18883          Skip to bottom
18884        </a>
18885        </section>
18886
18887      </aside>
18888
18889      <section class="card">
18890        <div class="card-header">
18891          <div class="card-title-row">
18892            <div>
18893              <h1 class="card-title">Guided scan configuration</h1>
18894              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18895            </div>
18896            <div class="wizard-progress" aria-label="Scan setup progress">
18897              <div class="wizard-progress-top">
18898                <span class="wizard-progress-label">Setup progress</span>
18899                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18900              </div>
18901              <div class="wizard-progress-track">
18902                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18903              </div>
18904            </div>
18905          </div>
18906        </div>
18907        <div class="card-body">
18908          <form method="post" action="/analyze" id="analyze-form">
18909            <div class="wizard-step active" data-step="1">
18910              <div class="section">
18911                <div class="section-kicker">Step 1</div>
18912                <h2>Select project and preview scope</h2>
18913                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18914                <div class="field">
18915                  <label for="path">Project path</label>
18916                  {% if !git_repo.is_empty() %}
18917                  <div class="git-source-banner">
18918                    <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>
18919                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18920                    <a href="/git-browser">← Back to Git Browser</a>
18921                  </div>
18922                  {% endif %}
18923                  <div class="path-scope-grid">
18924                      {% if !git_repo.is_empty() %}
18925                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18926                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18927                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18928                      {% else %}
18929                      <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18930                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18931                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18932                      {% endif %}
18933                    <div class="path-scope-sep"></div>
18934                    <div class="scope-legend-row">
18935                      <span class="scope-legend-label">Scope legend:</span>
18936                      <span class="scope-legend-badges">
18937                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18938                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18939                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18940                      </span>
18941                    </div>
18942                  </div>
18943                  {% if git_repo.is_empty() %}
18944                  {% if server_mode %}
18945                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18946                    ℹ️ Files are compressed and streamed — no fixed size limit.
18947                  </div>
18948                  {% endif %}
18949                  <div class="path-info-row">
18950                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18951                      <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>
18952                      <span id="project-size-text">Project size: —</span>
18953                    </button>
18954                  </div>
18955                  {% else %}
18956                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18957                  {% endif %}
18958                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18959                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18960                </div>
18961
18962                <div class="scope-preview-divider" aria-hidden="true"></div>
18963
18964                <div id="preview-panel">
18965                  <div class="preview-error">Loading preview...</div>
18966                </div>
18967              </div>
18968
18969              <div class="section" style="margin-top:14px;">
18970                <div class="preset-inline-row git-inline-row">
18971                  <div class="toggle-card" style="margin:0;">
18972                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18973                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18974                    <label class="checkbox">
18975                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18976                      <div>
18977                        <span>Detect and separate git submodules</span>
18978                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18979                      </div>
18980                    </label>
18981                  </div>
18982                  <div class="explainer-card prominent" style="margin:0;">
18983                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18984                    <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>
18985                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18986    path = libs/core
18987    url  = https://github.com/org/core.git
18988
18989[submodule "libs/ui"]
18990    path = libs/ui
18991    url  = https://github.com/org/ui.git</div>
18992                  </div>
18993                </div>
18994              </div>
18995
18996              <div class="section">
18997                <div class="field-grid">
18998                  <div class="field">
18999                    <div class="glob-label-row">
19000                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19001                      <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>
19002                    </div>
19003                    <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>
19004                    <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>
19005                  </div>
19006                  <div class="field">
19007                    <div class="glob-label-row">
19008                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19009                    </div>
19010                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
19011                    <div id="quick-exclude-chips" class="quick-excl-row">
19012                      <span class="quick-excl-label">Quick add:</span>
19013                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19014                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19015                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19016                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19017                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19018                      <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>
19019                    </div>
19020                    <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>
19021                  </div>
19022                </div>
19023                <div class="glob-guidance-grid">
19024                  <div class="glob-guidance-card">
19025                    <strong>How to read them</strong>
19026                    <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>
19027                  </div>
19028                  <div class="glob-guidance-card">
19029                    <strong>Common include examples</strong>
19030                    <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>
19031                  </div>
19032                  <div class="glob-guidance-card">
19033                    <strong>Common exclude examples</strong>
19034                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19035                  </div>
19036                </div>
19037              </div>
19038
19039              <div class="section" style="margin-top:14px;">
19040                <div class="preset-inline-row git-inline-row">
19041                  <div class="toggle-card" style="margin:0;">
19042                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19043                    <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>
19044                    <div class="field" style="margin:0;">
19045                      <div class="input-group compact">
19046                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19047                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19048                      </div>
19049                      <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>
19050                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19051                    </div>
19052                  </div>
19053                  <div class="explainer-card prominent" style="margin:0;">
19054                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19055                    <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>
19056                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19057lcov --capture --directory . --output-file coverage/lcov.info
19058
19059# C / C++ — llvm-cov (LCOV)
19060llvm-profdata merge -sparse default.profraw -o default.profdata
19061llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19062
19063# C# — coverlet (Cobertura XML)
19064dotnet test --collect:"XPlat Code Coverage"
19065
19066# Python — pytest-cov (Cobertura XML)
19067pytest --cov --cov-report=xml
19068
19069# Python — coverage.py native JSON
19070coverage run -m pytest && coverage json   # writes coverage.json
19071
19072# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19073./gradlew jacocoTestReport</div>
19074                  </div>
19075                </div>
19076              </div>
19077
19078              <div class="wizard-actions">
19079                <div class="left"></div>
19080                <div class="right">
19081                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19082                    <span class="preview-gate-spinner" aria-hidden="true"></span>
19083                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
19084                    <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">
19085                      <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>
19086                    </button>
19087                  </div>
19088                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19089                </div>
19090              </div>
19091            </div>
19092
19093            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19094              <div class="default-path-modal">
19095                <h3 id="default-path-title">
19096                  <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>
19097                  Proceed with the default sample test?
19098                </h3>
19099                <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19100                <p>You haven&#39;t selected your own project yet.</p>
19101                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19102                <div class="default-path-actions">
19103                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19104                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19105                </div>
19106              </div>
19107            </div>
19108
19109            <div class="wizard-step" data-step="2">
19110              <div class="section">
19111                <div class="section-kicker">Step 2</div>
19112                <h2>Choose counting behavior</h2>
19113                <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>
19114<div class="subsection-bar">Primary line classification</div>
19115                <div class="preset-kv-row">
19116                  <div class="toggle-card mixed-line-card" style="margin:0;">
19117                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19118                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19119                    <select id="mixed_line_policy" name="mixed_line_policy">
19120                      <option value="code_only">Code only</option>
19121                      <option value="code_and_comment">Code and comment</option>
19122                      <option value="comment_only">Comment only</option>
19123                      <option value="separate_mixed_category">Separate mixed category</option>
19124                    </select>
19125                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19126                  </div>
19127                  <div class="explainer-card prominent" style="margin:0;">
19128                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19129                    <div class="explainer-body" id="mixed-policy-description"></div>
19130                    <div class="code-sample" id="mixed-policy-example"></div>
19131                  </div>
19132                </div>
19133              </div>
19134
19135              <div class="subsection-bar">Additional scan rules</div>
19136              <div class="scan-rules-grid">
19137                <div class="preset-inline-row">
19138                  <div class="toggle-card" style="margin:0;">
19139                    <div class="field-help-title">Generated files</div>
19140                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19141                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19142                  </div>
19143                  <div class="explainer-card prominent" style="margin:0;">
19144                    <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>
19145                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19146# Files matching codegen patterns are excluded:
19147#   *.generated.cs  *.pb.go  *.g.dart</div>
19148                  </div>
19149                </div>
19150                <div class="preset-inline-row">
19151                  <div class="toggle-card" style="margin:0;">
19152                    <div class="field-help-title">Minified files</div>
19153                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19154                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19155                  </div>
19156                  <div class="explainer-card prominent" style="margin:0;">
19157                    <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>
19158                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19159# Heuristic: very long lines + low whitespace ratio
19160#   jquery.min.js  bundle.min.css  → skipped</div>
19161                  </div>
19162                </div>
19163                <div class="preset-inline-row">
19164                  <div class="toggle-card" style="margin:0;">
19165                    <div class="field-help-title">Vendor directories</div>
19166                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19167                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19168                  </div>
19169                  <div class="explainer-card prominent" style="margin:0;">
19170                    <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>
19171                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19172# Directories named vendor/ node_modules/ third_party/
19173#   → entire subtree is excluded from totals</div>
19174                  </div>
19175                </div>
19176                <div class="preset-inline-row">
19177                  <div class="toggle-card" style="margin:0;">
19178                    <div class="field-help-title">Lockfiles and manifests</div>
19179                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19180                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19181                  </div>
19182                  <div class="explainer-card prominent" style="margin:0;">
19183                    <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>
19184                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
19185# Files like package-lock.json  Cargo.lock  yarn.lock
19186#   → skipped unless this is enabled</div>
19187                  </div>
19188                </div>
19189                <div class="preset-inline-row">
19190                  <div class="toggle-card" style="margin:0;">
19191                    <div class="field-help-title">Binary handling</div>
19192                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19193                    <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>
19194                  </div>
19195                  <div class="explainer-card prominent" style="margin:0;">
19196                    <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>
19197                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
19198# Detected via long lines + low whitespace heuristic
19199#   .png  .exe  .so  → skipped silently</div>
19200                  </div>
19201                </div>
19202                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19203                  <div class="toggle-card" style="margin:0;">
19204                    <div class="field-help-title">Python docstrings</div>
19205                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19206                    <label class="checkbox">
19207                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19208                      <span>Count as comment-style lines</span>
19209                    </label>
19210                  </div>
19211                  <div class="explainer-card prominent" style="margin:0;">
19212                    <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>
19213                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19214                  </div>
19215                </div>
19216              </div>
19217              <div class="subsection-bar">IEEE 1045-1992 counting</div>
19218              <div class="scan-rules-grid">
19219                <div class="preset-inline-row">
19220                  <div class="toggle-card" style="margin:0;">
19221                    <div class="field-help-title">Continuation lines</div>
19222                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19223                    <select name="continuation_line_policy" id="continuation_line_policy">
19224                      <option value="each_physical_line" selected>Each physical line (default)</option>
19225                      <option value="collapse_to_logical">Collapse to logical line</option>
19226                    </select>
19227                  </div>
19228                  <div class="explainer-card prominent" style="margin:0;">
19229                    <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>
19230                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19231    ((a) &gt; (b) ? (a) : (b))
19232# each_physical_line → 2 SLOC
19233# collapse_to_logical → 1 SLOC</div>
19234                  </div>
19235                </div>
19236                <div class="preset-inline-row">
19237                  <div class="toggle-card" style="margin:0;">
19238                    <div class="field-help-title">Block-comment blanks</div>
19239                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19240                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19241                      <option value="count_as_comment" selected>Count as comment (default)</option>
19242                      <option value="count_as_blank">Count as blank</option>
19243                    </select>
19244                  </div>
19245                  <div class="explainer-card prominent" style="margin:0;">
19246                    <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>
19247                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19248 * Summary line
19249 *              ← blank inside block comment
19250 * Detail line
19251 */
19252# count_as_comment → blank counts toward comments
19253# count_as_blank   → blank counts toward blanks</div>
19254                  </div>
19255                </div>
19256                <div class="preset-inline-row">
19257                  <div class="toggle-card" style="margin:0;">
19258                    <div class="field-help-title">Compiler directives</div>
19259                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19260                    <select name="count_compiler_directives" id="count_compiler_directives">
19261                      <option value="enabled" selected>Include in code SLOC (default)</option>
19262                      <option value="disabled">Exclude from code SLOC</option>
19263                    </select>
19264                  </div>
19265                  <div class="explainer-card prominent" style="margin:0;">
19266                    <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>
19267                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
19268#define BUF 256     ← compiler directive
19269int main() { … }   ← code
19270# enabled  → 3 code SLOC
19271# disabled → 1 code SLOC + 2 directive lines</div>
19272                  </div>
19273                </div>
19274              </div>
19275
19276              <div class="subsection-bar">Code Style Analysis</div>
19277              <div class="scan-rules-grid">
19278                <div class="preset-inline-row">
19279                  <div class="toggle-card" style="margin:0;">
19280                    <div class="field-help-title">Style analysis</div>
19281                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19282                    <select name="style_analysis_enabled" id="style_analysis_enabled">
19283                      <option value="enabled" selected>Enabled (default)</option>
19284                      <option value="disabled">Disabled — skip style scoring</option>
19285                    </select>
19286                  </div>
19287                  <div class="explainer-card prominent" style="margin:0;">
19288                    <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>
19289                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
19290# style_analysis_enabled = false  (skip, faster scan)
19291# Disabling removes the Code Style section from the report.</div>
19292                  </div>
19293                </div>
19294                <div class="preset-inline-row">
19295                  <div class="toggle-card" style="margin:0;">
19296                    <div class="field-help-title">Column-width threshold</div>
19297                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19298                    <select name="style_col_threshold" id="style_col_threshold">
19299                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19300                      <option value="100">100 columns (Uber Go, Google Java)</option>
19301                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
19302                    </select>
19303                  </div>
19304                  <div class="explainer-card prominent" style="margin:0;">
19305                    <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>
19306                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
19307# style_col_threshold = 100 (Uber Go, Google Java)
19308# style_col_threshold = 120 (Uber Go max, Kotlin)
19309# Files where &lt;= 5% of lines exceed the limit
19310# are counted as "N-col compliant" in the report.</div>
19311                  </div>
19312                </div>
19313                <div class="preset-inline-row">
19314                  <div class="toggle-card" style="margin:0;">
19315                    <div class="field-help-title">Score alert threshold</div>
19316                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19317                    <select name="style_score_threshold" id="style_score_threshold">
19318                      <option value="0" selected>Off — no threshold (default)</option>
19319                      <option value="40">40% — flag poorly styled files</option>
19320                      <option value="50">50% — flag below-average files</option>
19321                      <option value="60">60% — flag below-good files</option>
19322                      <option value="70">70% — flag below-strong files</option>
19323                    </select>
19324                  </div>
19325                  <div class="explainer-card prominent" style="margin:0;">
19326                    <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>
19327                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
19328# style_score_threshold = 50  (flag files &lt; 50%)
19329# Low-scoring files get a red left-border in the
19330# per-file style breakdown table.</div>
19331                  </div>
19332                </div>
19333              </div>
19334
19335              <div class="always-tracked-tip">
19336                <div class="always-tracked-tip-icon">ℹ</div>
19337                <div class="always-tracked-tip-body">
19338                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
19339                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
19340                  <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>
19341                </div>
19342              </div>
19343
19344              <div class="subsection-bar">Advanced Metrics</div>
19345              <div class="scan-rules-grid">
19346                <div class="preset-inline-row">
19347                  <div class="toggle-card" style="margin:0;">
19348                    <div class="field-help-title">COCOMO mode</div>
19349                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19350                    <select name="cocomo_mode" id="cocomo_mode">
19351                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19352                      <option value="semi_detached">Semi-detached — mixed constraints</option>
19353                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
19354                    </select>
19355                  </div>
19356                  <div class="explainer-card prominent" style="margin:0;">
19357                    <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>
19358                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
19359# Semi-detached: Effort = 3.0 × KSLOC^1.12
19360# Embedded:     Effort = 3.6 × KSLOC^1.20
19361# All modes: Schedule = 2.5 × Effort^d</div>
19362                  </div>
19363                </div>
19364                <div class="preset-inline-row">
19365                  <div class="toggle-card" style="margin:0;">
19366                    <div class="field-help-title">Complexity alert</div>
19367                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19368                    <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;" />
19369                  </div>
19370                  <div class="explainer-card prominent" style="margin:0;">
19371                    <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>
19372                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19373# 50  = flag any file with &gt; 50 branch points
19374# 100 = flag any file with &gt; 100 branch points
19375# Files above the threshold are highlighted
19376# in the result page metric strip.</div>
19377                  </div>
19378                </div>
19379                <div class="preset-inline-row">
19380                  <div class="toggle-card" style="margin:0;">
19381                    <div class="field-help-title">Git hotspots</div>
19382                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19383                    <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;" />
19384                  </div>
19385                  <div class="explainer-card prominent" style="margin:0;">
19386                    <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>
19387                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
19388# 30  = last month of activity
19389# 365 = last year
19390# 0   = disable the hotspots table
19391# Adds Commits + Last-changed columns to CSV.</div>
19392                  </div>
19393                </div>
19394                <div class="preset-inline-row">
19395                  <div class="toggle-card" style="margin:0;">
19396                    <div class="field-help-title">Duplicate handling</div>
19397                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19398                    <select name="exclude_duplicates" id="exclude_duplicates">
19399                      <option value="disabled" selected>Detect and report only (default)</option>
19400                      <option value="enabled">Detect and exclude from SLOC totals</option>
19401                    </select>
19402                  </div>
19403                  <div class="explainer-card prominent" style="margin:0;">
19404                    <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>
19405                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19406# detect only   → all 3 counted in SLOC
19407# exclude dupes → 1 counted, 2 excluded
19408# Duplicate groups chip always shows the count.</div>
19409                  </div>
19410                </div>
19411                <div class="always-tracked-tip" style="margin:8px 0 0;">
19412                  <div class="always-tracked-tip-icon">ℹ</div>
19413                  <div class="always-tracked-tip-body">
19414                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
19415                    <div class="always-tracked-metrics-row">
19416                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19417                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
19418                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
19419                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
19420                    </div>
19421                    <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>
19422                  </div>
19423                </div>
19424              </div>
19425
19426              <div class="wizard-actions">
19427                <div class="left">
19428                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19429                </div>
19430                <div class="right">
19431                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19432                </div>
19433              </div>
19434            </div>
19435
19436            <div class="wizard-step" data-step="3">
19437              <div class="section">
19438                <div class="section-kicker">Step 3</div>
19439                <h2>Output and report identity</h2>
19440                <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>
19441                <div class="preset-kv-row">
19442                  <div class="toggle-card" style="margin:0;">
19443                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19444                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19445                    <select id="scan_preset">
19446                      <option value="balanced">Balanced local scan</option>
19447                      <option value="code_focused">Code focused</option>
19448                      <option value="comment_audit">Comment audit</option>
19449                      <option value="deep_review">Deep review</option>
19450                    </select>
19451                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19452                  </div>
19453                  <div class="explainer-card">
19454                    <div class="field-help-title">Selected scan preset</div>
19455                    <div class="explainer-body" id="scan-preset-description"></div>
19456                    <div class="preset-summary-row" id="scan-preset-summary"></div>
19457                    <div class="code-sample" id="scan-preset-example"></div>
19458                    <div class="preset-note" id="scan-preset-note"></div>
19459                  </div>
19460                </div>
19461                <hr class="step3-separator" />
19462                <div class="preset-kv-row">
19463                  <div class="toggle-card" style="margin:0;">
19464                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19465                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19466                    <select id="artifact_preset">
19467                      <option value="review">Review bundle</option>
19468                      <option value="full">Full bundle</option>
19469                      <option value="html_only">HTML only</option>
19470                      <option value="machine">Machine bundle</option>
19471                    </select>
19472                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19473                  </div>
19474                  <div class="explainer-card">
19475                    <div class="field-help-title">Selected artifact preset</div>
19476                    <div class="explainer-body" id="artifact-preset-description"></div>
19477                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
19478                    <div class="code-sample" id="artifact-preset-example"></div>
19479                  </div>
19480                </div>
19481              </div>
19482
19483              <div class="section section-spacer-top">
19484                <div class="output-field-row">
19485                  <div class="field">
19486                    <label for="output_dir">Output directory</label>
19487                    {% if server_mode %}
19488                    <div class="input-group compact">
19489                      <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);" />
19490                    </div>
19491                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19492                    {% else %}
19493                    <div class="input-group compact">
19494                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19495                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19496                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
19497                    </div>
19498                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19499                    {% endif %}
19500                  </div>
19501                  <div class="output-field-aside">
19502                    <strong>Where reports land</strong>
19503                    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.
19504                  </div>
19505                </div>
19506              </div>
19507
19508              <div class="section section-spacer-top">
19509                <div class="output-field-row">
19510                  <div class="field">
19511                    <label for="report_title">Report title</label>
19512                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19513                    <div class="hint">Appears in HTML and PDF output headers.</div>
19514                  </div>
19515                  <div class="output-field-aside">
19516                    <strong>Shown in exported artifacts</strong>
19517                    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.
19518                  </div>
19519                </div>
19520              </div>
19521
19522              <div class="section section-spacer-top">
19523                <div class="output-field-row">
19524                  <div class="field">
19525                    <label for="report_header_footer">Report header / footer</label>
19526                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19527                    <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>
19528                  </div>
19529                  <div class="output-field-aside">
19530                    <strong>Page-level identification</strong>
19531                    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.
19532                  </div>
19533                </div>
19534              </div>
19535
19536              <div class="wizard-actions">
19537                <div class="left">
19538                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19539                </div>
19540                <div class="right">
19541                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19542                </div>
19543              </div>
19544            </div>
19545
19546            <div class="wizard-step" data-step="4">
19547              <div class="section">
19548                <div class="section-kicker">Step 4</div>
19549                <h2>Review selections and run</h2>
19550                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19551                <div class="review-grid">
19552                  <div class="review-card highlight">
19553                    <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>
19554                    <ul id="review-scan-summary"></ul>
19555                  </div>
19556                  <div class="review-card highlight">
19557                    <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>
19558                    <ul id="review-count-summary"></ul>
19559                  </div>
19560                  <div class="review-card">
19561                    <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>
19562                    <ul id="review-artifact-summary"></ul>
19563                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19564                  </div>
19565                  <div class="review-card">
19566                    <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>
19567                    <ul id="review-preview-summary"></ul>
19568                  </div>
19569                </div>
19570              </div>
19571
19572              <div class="wizard-actions">
19573                <div class="left">
19574                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19575                </div>
19576                <div class="right">
19577                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
19578                </div>
19579              </div>
19580            </div>
19581            {% if server_mode %}
19582            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19583            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19584            {% endif %}
19585          </form>
19586        </div>
19587      </section>
19588    </div>
19589  </div>
19590
19591  <script nonce="{{ csp_nonce }}">
19592    (function () {
19593      function startScanPhase() {
19594        var phaseEl = document.getElementById("scan-phase");
19595        if (!phaseEl) return;
19596        var phases = [
19597          "Discovering files...",
19598          "Decoding file encodings...",
19599          "Detecting languages...",
19600          "Analyzing source lines...",
19601          "Applying counting policies...",
19602          "Aggregating results...",
19603          "Rendering report..."
19604        ];
19605        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19606        var i = 0;
19607        function next() {
19608          phaseEl.style.opacity = "0";
19609          setTimeout(function () {
19610            phaseEl.textContent = phases[i];
19611            phaseEl.style.opacity = "0.85";
19612            var delay = durations[i] || 1800;
19613            i++;
19614            if (i < phases.length) { setTimeout(next, delay); }
19615          }, 200);
19616        }
19617        next();
19618      }
19619
19620      var form = document.getElementById("analyze-form");
19621      var loading = document.getElementById("loading");
19622      var submitButton = document.getElementById("submit-button");
19623      var pathInput = document.getElementById("path");
19624      var GIT_MODE = !!(pathInput && pathInput.readOnly);
19625      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19626      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19627      var outputDirInput = document.getElementById("output_dir");
19628      var reportTitleInput = document.getElementById("report_title");
19629      var previewPanel = document.getElementById("preview-panel");
19630      var refreshButton = document.getElementById("refresh-preview");
19631      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19632      var useSamplePath = document.getElementById("use-sample-path");
19633      var useDefaultOutput = document.getElementById("use-default-output");
19634      var browsePath = document.getElementById("browse-path");
19635      var browseOutputDir = document.getElementById("browse-output-dir");
19636      var browseCoverage = document.getElementById("browse-coverage");
19637      var coverageInput = document.getElementById("coverage_file");
19638      var covScanStatus = document.getElementById("cov-scan-status");
19639      var coverageSuggestTimer = null;
19640      var covAutoFilled = false;
19641      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19642
19643      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19644      (function() {
19645        var ids = ["path", "output_dir"];
19646        ids.forEach(function(id) {
19647          var el = document.getElementById(id);
19648          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19649        });
19650      }());
19651      function fmtBytes(b) {
19652        b = Number(b) || 0;
19653        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19654        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19655        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
19656        return b + ' B';
19657      }
19658      var themeToggle = document.getElementById("theme-toggle");
19659
19660      function showBannerToast(msg, isError, opts) {
19661        opts = opts || {};
19662        var t = document.createElement('div');
19663        t.className = isError ? 'toast-error' : 'toast-success';
19664        var topPos = opts.top ? '80px' : null;
19665        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19666          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19667          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19668          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19669        if (opts.icon) {
19670          var inner = document.createElement('span');
19671          inner.innerHTML = opts.icon + ' ';
19672          t.appendChild(inner);
19673        }
19674        t.appendChild(document.createTextNode(msg));
19675        document.body.appendChild(t);
19676        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19677      }
19678      var mixedLinePolicy = document.getElementById("mixed_line_policy");
19679      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19680      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19681      var scanPreset = document.getElementById("scan_preset");
19682      var artifactPreset = document.getElementById("artifact_preset");
19683      var includeGlobsInput = document.getElementById("include_globs");
19684      var excludeGlobsInput = document.getElementById("exclude_globs");
19685
19686      // Include globs scope badge — updates reactively as the user types.
19687      (function() {
19688        var badge = document.getElementById("include-scope-badge");
19689        if (!badge || !includeGlobsInput) return;
19690        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> ';
19691        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> ';
19692        function update() {
19693          var val = includeGlobsInput.value.trim();
19694          if (!val) {
19695            badge.className = "include-scope-badge scope-all";
19696            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19697          } else {
19698            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19699            badge.className = "include-scope-badge scope-narrow";
19700            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19701          }
19702        }
19703        includeGlobsInput.addEventListener("input", update);
19704        update();
19705      }());
19706
19707      // Quick-exclude chips — append pattern to exclude_globs textarea.
19708      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19709        chip.addEventListener("click", function() {
19710          var pattern = chip.getAttribute("data-pattern") || "";
19711          if (!pattern || !excludeGlobsInput) return;
19712          var current = excludeGlobsInput.value.trim();
19713          // For the "skip all" chip, replace any existing dep patterns cleanly.
19714          var patterns = pattern.split("\n");
19715          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19716          var added = false;
19717          patterns.forEach(function(p) {
19718            p = p.trim();
19719            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19720          });
19721          if (added) {
19722            excludeGlobsInput.value = lines.join("\n");
19723            excludeGlobsInput.dispatchEvent(new Event("input"));
19724          }
19725          chip.classList.add("active");
19726        });
19727      });
19728
19729      var liveReportTitle = document.getElementById("live-report-title");
19730      var navProjectPill = document.getElementById("nav-project-pill");
19731      var navProjectTitle = document.getElementById("nav-project-title");
19732      var reportTitlePreview = null;
19733      var wizardProgressFill = document.getElementById("wizard-progress-fill");
19734      var wizardProgressValue = document.getElementById("wizard-progress-value");
19735      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19736      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19737      var reportTitleTouched = false;
19738      var currentStep = 1;
19739      var previewTimer = null;
19740      var _previewGen = 0;
19741      // True while the scope preview (local) / project upload (server mode) is in
19742      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19743      // user can't advance past a project whose scope/upload isn't ready yet.
19744      var previewLoading = false;
19745      // Set when the current preview reports multiple independent git repos under
19746      // the selected root. Advancing past step 1 is blocked until the user ticks
19747      // the acknowledgement checkbox (or re-selects a single repository).
19748      var multiRepoBlocked = false;
19749      function step1ForwardBlocked() {
19750        return previewLoading || multiRepoBlocked;
19751      }
19752      function refreshStep1Gate() {
19753        var nextBtn = document.getElementById("step1-next");
19754        if (nextBtn) {
19755          var blocked = step1ForwardBlocked();
19756          nextBtn.classList.toggle("is-blocked", blocked);
19757          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19758        }
19759      }
19760      function setPreviewLoading(loading) {
19761        previewLoading = !!loading;
19762        var gate = document.getElementById("preview-gate-status");
19763        refreshStep1Gate();
19764        if (gate) {
19765          var txt = gate.querySelector(".preview-gate-text");
19766          if (txt) txt.textContent = SERVER_MODE
19767            ? "Uploading & scanning project…"
19768            : "Scanning project scope…";
19769          gate.style.display = previewLoading ? "flex" : "none";
19770        }
19771      }
19772      // Info button on the gate: scroll up to the live scope preview so the user
19773      // can see exactly what is being scanned (elapsed time + rotating status).
19774      var previewGateInfo = document.getElementById("preview-gate-info");
19775      if (previewGateInfo) {
19776        previewGateInfo.addEventListener("click", function () {
19777          var target = document.getElementById("preview-panel");
19778          if (!target) return;
19779          target.scrollIntoView({ behavior: "smooth", block: "center" });
19780          target.classList.add("preview-panel-flash");
19781          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19782        });
19783      }
19784      var quickScanBtn = document.getElementById("quick-scan-btn");
19785
19786      function dismissAnalysisModal() {
19787        if (loading) loading.classList.remove("active");
19788        document.body.classList.remove("modal-open");
19789        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19790          var el = document.getElementById(id);
19791          if (el) el.classList.add("hidden");
19792        });
19793        var cancelBtn = document.getElementById("lc-cancel-btn");
19794        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19795        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19796        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19797        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19798        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");}
19799        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19800        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19801        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19802        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19803        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19804        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19805      }
19806
19807      var lcDismissBtn = document.getElementById("lc-dismiss");
19808      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19809
19810      // When the browser restores this page from bfcache (Back button after navigating to results),
19811      // the loading overlay would still be showing its active state. Dismiss it immediately.
19812      window.addEventListener("pageshow", function(e) {
19813        if (e.persisted) { dismissAnalysisModal(); }
19814      });
19815
19816      function startAsyncAnalysis(formData) {
19817        var gitRepo = (formData.get("git_repo") || "").toString();
19818        var gitRef  = (formData.get("git_ref")  || "").toString();
19819        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19820        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19821
19822        var pathEl = document.getElementById("lc-path-text");
19823        if (pathEl) pathEl.textContent = displayPath;
19824
19825        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19826          var el = document.getElementById(id);
19827          if (el) el.classList.add("hidden");
19828        });
19829        var cancelBtn = document.getElementById("lc-cancel-btn");
19830        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19831        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19832        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19833        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19834        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19835        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
19836        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19837        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");}
19838        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19839
19840        if (loading) loading.classList.add("active");
19841        document.body.classList.add("modal-open");
19842
19843        var startTime = Date.now();
19844        var elapsedTimer = setInterval(function() {
19845          var s = Math.floor((Date.now() - startTime) / 1000);
19846          var el = document.getElementById("lc-elapsed");
19847          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19848        }, 1000);
19849
19850        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19851
19852        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();}
19853
19854        var PHASE_DESC = {
19855          'Starting': 'Initializing language analyzers and loading configuration\u2026',
19856          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19857          'Running': 'Running the lexical state machine across all discovered source files\u2026',
19858          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19859          'Done': 'Analysis complete \u2014 loading your results\u2026',
19860          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19861        };
19862        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19863        function lcSetPhase(txt) {
19864          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19865          var desc = document.getElementById("lc-stage-desc");
19866          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19867          var step = PHASE_STEP[txt] || 1;
19868          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");}
19869        }
19870
19871        function lcShowCancelled() {
19872          clearInterval(elapsedTimer);
19873          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19874          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19875          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19876          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19877          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19878          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19879          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19880          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19881          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19882          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19883        }
19884
19885        var lcCancelBtn = document.getElementById("lc-cancel-btn");
19886        if (lcCancelBtn) {
19887          lcCancelBtn.onclick = function() {
19888            if (!activeWaitId) { dismissAnalysisModal(); return; }
19889            lcCancelBtn.disabled = true;
19890            lcCancelBtn.textContent = "Cancelling\u2026";
19891            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19892              .then(function() { lcShowCancelled(); })
19893              .catch(function() { lcShowCancelled(); });
19894          };
19895        }
19896
19897        function lcShowError(msg) {
19898          clearInterval(elapsedTimer);
19899          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19900          lcSetPhase("Failed");
19901          var msgEl = document.getElementById("lc-err-msg");
19902          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19903          var errEl = document.getElementById("lc-err");
19904          var actEl = document.getElementById("lc-actions");
19905          if (errEl) errEl.classList.remove("hidden");
19906          if (actEl) actEl.classList.remove("hidden");
19907          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19908          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19909        }
19910
19911        function lcPoll(waitId) {
19912          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19913            .then(function(r) {
19914              if (!r.ok) throw new Error("HTTP " + r.status);
19915              return r.json();
19916            })
19917            .then(function(data) {
19918              pollRetries = 0;
19919              if (data.state === "complete") {
19920                clearInterval(elapsedTimer);
19921                lcSetPhase("Done");
19922                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19923              } else if (data.state === "failed") {
19924                lcShowError(data.message);
19925              } else if (data.state === "cancelled") {
19926                lcShowCancelled();
19927              } else {
19928                var s = Math.floor((Date.now() - startTime) / 1000);
19929                if (s > 90 && !warnShown) {
19930                  warnShown = true;
19931                  var w = document.getElementById("lc-warn");
19932                  if (w) w.classList.remove("hidden");
19933                }
19934                lcSetPhase(data.phase || "Running");
19935                var fd = data.files_done || 0, ft = data.files_total || 0;
19936                if (ft > 0) {
19937                  var card = document.getElementById("lc-files-card");
19938                  if (card) card.classList.remove("hidden");
19939                  var el = document.getElementById("lc-files");
19940                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19941                  var now = Date.now();
19942                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19943                  if (fdelta > 0 && tdelta > 0.4) {
19944                    var fps = Math.round(fdelta / tdelta);
19945                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19946                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19947                  }
19948                  lastFd = fd; lastFdTime = now;
19949                }
19950                setTimeout(function() { lcPoll(waitId); }, 1500);
19951              }
19952            })
19953            .catch(function() {
19954              pollRetries++;
19955              if (pollRetries >= 5) {
19956                lcShowError("Lost connection to server. Reload to check status.");
19957              } else {
19958                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19959              }
19960            });
19961        }
19962
19963        var params = new URLSearchParams(formData);
19964        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19965          .then(function(r) {
19966            var waitId = r.headers.get("x-wait-id");
19967            if (!waitId) { window.location.href = "/scan"; return; }
19968            activeWaitId = waitId;
19969            setTimeout(function() { lcPoll(waitId); }, 1500);
19970          })
19971          .catch(function(err) {
19972            lcShowError("Could not reach server: " + (err.message || err));
19973          });
19974      }
19975
19976      if (quickScanBtn) {
19977        quickScanBtn.addEventListener("click", function () {
19978          var pathVal = pathInput ? pathInput.value.trim() : "";
19979          if (!pathVal) {
19980            alert("Please enter or browse to a project path first.");
19981            return;
19982          }
19983          quickScanBtn.disabled = true;
19984          quickScanBtn.textContent = "Scanning...";
19985          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19986          startAsyncAnalysis(new FormData(form));
19987        });
19988      }
19989
19990      var mixedPolicyInfo = {
19991        code_only: {
19992          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.",
19993          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'
19994        },
19995        code_and_comment: {
19996          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.",
19997          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'
19998        },
19999        comment_only: {
20000          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.",
20001          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'
20002        },
20003        separate_mixed_category: {
20004          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.",
20005          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'
20006        }
20007      };
20008
20009      var scanPresetInfo = {
20010        balanced: {
20011          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.",
20012          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20013          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20014          note: "Best when you want a stable local overview before making deeper adjustments.",
20015          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20016        },
20017        code_focused: {
20018          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20019          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20020          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20021          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20022          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20023        },
20024        comment_audit: {
20025          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20026          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20027          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20028          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20029          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20030        },
20031        deep_review: {
20032          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20033          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20034          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20035          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20036          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20037        }
20038      };
20039
20040      var artifactPresetInfo = {
20041        review: {
20042          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20043          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20044          example: "Ideal for a quick local review before sharing results."
20045        },
20046        full: {
20047          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20048          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20049          example: "Use when producing a deliverable or storing a snapshot for future comparison."
20050        },
20051        html_only: {
20052          description: "Standalone HTML report only. No PDF generation, no data files.",
20053          chips: ["HTML only"],
20054          example: "Fastest option when you only need to open the report in a browser."
20055        },
20056        machine: {
20057          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20058          chips: ["JSON", "CSV", "no HTML", "no PDF"],
20059          example: "Use in CI to capture metrics without generating visual reports."
20060        }
20061      };
20062
20063      function applyArtifactPreset() {
20064        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20065        if (!info) return;
20066        var descEl = document.getElementById("artifact-preset-description");
20067        var exampleEl = document.getElementById("artifact-preset-example");
20068        if (descEl) descEl.textContent = info.description;
20069        if (exampleEl) exampleEl.textContent = info.example;
20070        renderPresetChips("artifact-preset-summary", info.chips);
20071      }
20072
20073      function applyTheme(theme) {
20074        if (theme === "dark") document.body.classList.add("dark-theme");
20075        else document.body.classList.remove("dark-theme");
20076      }
20077
20078      function loadSavedTheme() {
20079        var saved = null;
20080        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20081        applyTheme(saved === "dark" ? "dark" : "light");
20082      }
20083
20084      function updateScrollProgress() {
20085        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20086        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20087        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
20088        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20089        var step = Math.min(Math.max(currentStep, 1), 4);
20090        var base = stepBase[step];
20091        var end  = stepEnd[step];
20092
20093        var scrollFrac = 0;
20094        var activePanel = document.querySelector(".wizard-step.active");
20095        if (activePanel) {
20096          var scrollTop = window.scrollY || window.pageYOffset || 0;
20097          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20098          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20099          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20100          var scrolled = scrollTop + viewH - panelTop;
20101          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20102        }
20103
20104        var percent = Math.round(base + (end - base) * scrollFrac);
20105        percent = Math.min(end, Math.max(base, percent));
20106        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20107        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20108      }
20109
20110      function updateWizardProgress() {
20111        updateScrollProgress();
20112      }
20113
20114      var stepDescriptions = [
20115        "Choose a project folder, apply scope filters, and preview which files will be counted.",
20116        "Configure how mixed code-plus-comment lines and docstrings are classified.",
20117        "Pick your output formats, scan preset, and where reports are saved.",
20118        "Review all settings and launch the analysis."
20119      ];
20120
20121      function updateStepNav(step) {
20122        var infoLabel = document.getElementById("step-nav-info-label");
20123        var infoDesc  = document.getElementById("step-nav-info-desc");
20124        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20125        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
20126      }
20127
20128      function updateSidebarSummary() {
20129        var sumPath    = document.getElementById("sum-path");
20130        var sumPreset  = document.getElementById("sum-preset");
20131        var sumOutput  = document.getElementById("sum-output");
20132        var sidebarSummary = document.getElementById("sidebar-summary");
20133        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20134        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
20135        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20136        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
20137        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20138        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20139        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20140      }
20141
20142      function setStep(step, pushHistory) {
20143        currentStep = step;
20144        stepPanels.forEach(function (panel) {
20145          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20146        });
20147        stepButtons.forEach(function (button) {
20148          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20149        });
20150        var layoutEl = document.querySelector(".layout");
20151        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20152        updateWizardProgress();
20153        updateStepNav(step);
20154        stepButtons.forEach(function(btn) {
20155          var t = Number(btn.getAttribute("data-step-target"));
20156          btn.classList.toggle("done", t < step);
20157        });
20158        updateSidebarSummary();
20159
20160        if (pushHistory !== false) {
20161          try {
20162            history.pushState({ wizardStep: step }, "", "#step" + step);
20163          } catch (e) {}
20164        }
20165
20166        window.scrollTo({ top: 0, behavior: "instant" });
20167      }
20168
20169      window.addEventListener("popstate", function (e) {
20170        if (e.state && e.state.wizardStep) {
20171          setStep(e.state.wizardStep, false);
20172        } else {
20173          var hashMatch = location.hash.match(/^#step([1-4])$/);
20174          if (hashMatch) setStep(Number(hashMatch[1]), false);
20175        }
20176      });
20177
20178      function inferTitleFromPath(value) {
20179        if (!value) return "project";
20180        var cleaned = value.replace(/[\/\\]+$/, "");
20181        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20182        return parts.length ? parts[parts.length - 1] : value;
20183      }
20184
20185      function updateReportTitleFromPath() {
20186        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20187        if (!reportTitleTouched) {
20188          reportTitleInput.value = inferred;
20189        }
20190        var title = reportTitleInput.value || inferred;
20191        if (liveReportTitle) liveReportTitle.textContent = title;
20192        if (reportTitlePreview) reportTitlePreview.textContent = title;
20193        document.title = "OxideSLOC | " + title;
20194
20195        var projectPath = (pathInput.value || "").trim();
20196        if (navProjectPill && navProjectTitle) {
20197          if (projectPath.length > 0) {
20198            navProjectTitle.textContent = inferred;
20199            navProjectPill.classList.add("visible");
20200          } else {
20201            navProjectTitle.textContent = "";
20202            navProjectPill.classList.remove("visible");
20203          }
20204        }
20205      }
20206
20207      function updateMixedPolicyUI() {
20208        var key = mixedLinePolicy.value || "code_only";
20209        var info = mixedPolicyInfo[key];
20210        document.getElementById("mixed-policy-description").textContent = info.description;
20211        document.getElementById("mixed-policy-example").textContent = info.example;
20212      }
20213
20214      function updatePythonDocstringUI() {
20215        var checked = !!pythonDocstrings.checked;
20216        document.getElementById("python-docstring-example").textContent = checked
20217          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
20218          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
20219        document.getElementById("python-docstring-live-help").textContent = checked
20220          ? "Enabled: docstrings contribute to comment-style totals."
20221          : "Disabled: docstrings are not counted as comment content.";
20222      }
20223
20224      function renderPresetChips(targetId, chips) {
20225        var target = document.getElementById(targetId);
20226        if (!target) return;
20227        target.innerHTML = (chips || []).map(function (chip) {
20228          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20229        }).join('');
20230      }
20231
20232      function updatePresetDescriptions() {
20233        var scanInfo = scanPresetInfo[scanPreset.value];
20234        if (!scanInfo) return;
20235        document.getElementById("scan-preset-description").textContent = scanInfo.description;
20236        document.getElementById("scan-preset-example").textContent = scanInfo.example;
20237        document.getElementById("scan-preset-note").textContent = scanInfo.note;
20238        renderPresetChips("scan-preset-summary", scanInfo.chips);
20239      }
20240
20241      function applyScanPreset() {
20242        var info = scanPresetInfo[scanPreset.value];
20243        if (!info || !info.apply) return;
20244        mixedLinePolicy.value = info.apply.mixed;
20245        pythonDocstrings.checked = !!info.apply.docstrings;
20246        document.getElementById("generated_file_detection").value = info.apply.generated;
20247        document.getElementById("minified_file_detection").value = info.apply.minified;
20248        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20249        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20250        document.getElementById("binary_file_behavior").value = info.apply.binary;
20251        updateMixedPolicyUI();
20252        updatePythonDocstringUI();
20253      }
20254
20255      function updateReview() {
20256        var scanSummary = document.getElementById("review-scan-summary");
20257        var countSummary = document.getElementById("review-count-summary");
20258        var artifactSummary = document.getElementById("review-artifact-summary");
20259        var outputSummary = document.getElementById("review-output-summary");
20260        var previewSummary = document.getElementById("review-preview-summary");
20261        var readinessSummary = document.getElementById("review-readiness-summary");
20262        var includeText = document.getElementById("include_globs").value.trim();
20263        var excludeText = document.getElementById("exclude_globs").value.trim();
20264        var sidePathPreview = document.getElementById("side-path-preview");
20265        var sideOutputPreview = document.getElementById("side-output-preview");
20266        var sideTitlePreview = document.getElementById("side-title-preview");
20267
20268        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20269        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20270        if (sideTitlePreview) {
20271          var rt = document.getElementById("report_title");
20272          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20273        }
20274
20275        scanSummary.innerHTML = ""
20276          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20277          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20278          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20279
20280        countSummary.innerHTML = ""
20281          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20282          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20283          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20284          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20285          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20286          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20287          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20288          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20289
20290        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20291
20292        outputSummary.innerHTML = ""
20293          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20294          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20295
20296        if (previewSummary) {
20297          if (GIT_MODE) {
20298            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>';
20299          } else {
20300          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20301          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20302          var statMap = {};
20303          statButtons.forEach(function (button) {
20304            var valueNode = button.querySelector('.scope-stat-value');
20305            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20306          });
20307          previewSummary.innerHTML = ''
20308            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20309            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20310            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20311            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20312            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20313            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20314
20315          if (readinessSummary) {
20316            readinessSummary.innerHTML = ''
20317              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20318              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20319              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20320          }
20321          } // end else (non-GIT_MODE)
20322        }
20323      }
20324
20325      function escapeHtml(value) {
20326        return String(value)
20327          .replace(/&/g, "&amp;")
20328          .replace(/</g, "&lt;")
20329          .replace(/>/g, "&gt;")
20330          .replace(/"/g, "&quot;")
20331          .replace(/'/g, "&#39;");
20332      }
20333
20334      function isPythonVisible() {
20335        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20336      }
20337
20338      function syncPythonVisibility() {
20339        var html = previewPanel.textContent || "";
20340        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20341        pythonWraps.forEach(function (node) {
20342          node.classList.toggle("hidden", !hasPython);
20343        });
20344      }
20345
20346      function attachPreviewInteractions() {
20347        // Multiple-repository caution banner: gate step 1 until acknowledged, and
20348        // let each listed repo be picked as the scan root with one click.
20349        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20350        if (multiRepoBanner) {
20351          multiRepoBlocked = true;
20352          refreshStep1Gate();
20353          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20354          if (ackBox) {
20355            ackBox.addEventListener("change", function () {
20356              multiRepoBlocked = !ackBox.checked;
20357              refreshStep1Gate();
20358            });
20359          }
20360          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20361          repoButtons.forEach(function (btn) {
20362            btn.addEventListener("click", function () {
20363              var repoPath = btn.getAttribute("data-repo-path") || "";
20364              if (!repoPath || !pathInput) return;
20365              pathInput.value = repoPath;
20366              scrollInputToEnd(pathInput);
20367              updateReportTitleFromPath();
20368              autoSetOutputDir(repoPath);
20369              fetchProjectHistory(repoPath);
20370              loadPreview();
20371              updateReview();
20372            });
20373          });
20374        }
20375        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20376        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20377        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20378        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20379        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20380        var searchInput = previewPanel.querySelector("#explorer-search");
20381        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20382        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20383        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20384        var activeFilter = "all";
20385        var activeLanguage = "";
20386        var searchTerm = "";
20387        var currentSortKey = null;
20388        var currentSortOrder = "asc";
20389        var childRows = {};
20390
20391        rows.forEach(function (row) {
20392          var parentId = row.getAttribute("data-parent-id") || "";
20393          var rowId = row.getAttribute("data-row-id") || "";
20394          if (!childRows[parentId]) childRows[parentId] = [];
20395          childRows[parentId].push(rowId);
20396        });
20397
20398        function rowById(id) {
20399          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20400        }
20401
20402        function hasCollapsedAncestor(row) {
20403          var parentId = row.getAttribute("data-parent-id");
20404          while (parentId) {
20405            var parent = rowById(parentId);
20406            if (!parent) break;
20407            if (parent.getAttribute("data-expanded") === "false") return true;
20408            parentId = parent.getAttribute("data-parent-id");
20409          }
20410          return false;
20411        }
20412
20413        function updateToggleGlyph(row) {
20414          var toggle = row.querySelector(".tree-toggle");
20415          if (!toggle) return;
20416          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20417        }
20418
20419        function rowSortValue(row, key) {
20420          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20421        }
20422
20423        function updateSortButtons() {
20424          sortButtons.forEach(function (button) {
20425            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20426            var indicator = button.querySelector(".tree-sort-indicator");
20427            button.classList.toggle("active", isActive);
20428            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20429            if (indicator) {
20430              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20431            }
20432          });
20433        }
20434
20435        function sortSiblingRows() {
20436          if (!treeContainer) {
20437            updateSortButtons();
20438            return;
20439          }
20440
20441          var rowMap = {};
20442          var childrenMap = {};
20443          rows.forEach(function (row) {
20444            var rowId = row.getAttribute("data-row-id");
20445            var parentId = row.getAttribute("data-parent-id") || "";
20446            rowMap[rowId] = row;
20447            if (!childrenMap[parentId]) childrenMap[parentId] = [];
20448            childrenMap[parentId].push(rowId);
20449          });
20450
20451          Object.keys(childrenMap).forEach(function (parentId) {
20452            if (!parentId) return;
20453            childrenMap[parentId].sort(function (a, b) {
20454              var rowA = rowMap[a];
20455              var rowB = rowMap[b];
20456              if (!currentSortKey) {
20457                return Number(a) - Number(b);
20458              }
20459              var valueA = rowSortValue(rowA, currentSortKey);
20460              var valueB = rowSortValue(rowB, currentSortKey);
20461              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20462              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20463              var fallbackA = rowSortValue(rowA, "name");
20464              var fallbackB = rowSortValue(rowB, "name");
20465              if (fallbackA < fallbackB) return -1;
20466              if (fallbackA > fallbackB) return 1;
20467              return Number(a) - Number(b);
20468            });
20469          });
20470
20471          var orderedIds = [];
20472          function pushChildren(parentId) {
20473            (childrenMap[parentId] || []).forEach(function (childId) {
20474              orderedIds.push(childId);
20475              pushChildren(childId);
20476            });
20477          }
20478
20479          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20480            orderedIds.push(topId);
20481            pushChildren(topId);
20482          });
20483
20484          orderedIds.forEach(function (id) {
20485            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20486          });
20487          updateSortButtons();
20488        }
20489
20490        function updateLanguageButtons() {
20491          languageButtons.forEach(function (button) {
20492            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20493            var isActive = languageValue === activeLanguage;
20494            button.classList.toggle("active", isActive);
20495          });
20496        }
20497
20498        function rowSelfMatches(row) {
20499          var kind = row.getAttribute("data-kind");
20500          var status = row.getAttribute("data-status");
20501          var language = (row.getAttribute("data-language") || "").toLowerCase();
20502          var name = row.getAttribute("data-name-lower") || "";
20503          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20504          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20505          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20506          var passesLanguage = !activeLanguage || language === activeLanguage;
20507          return passesFilter && passesSearch && passesLanguage;
20508        }
20509
20510        function hasMatchingDescendant(rowId) {
20511          return (childRows[rowId] || []).some(function (childId) {
20512            var childRow = rowById(childId);
20513            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20514          });
20515        }
20516
20517        function rowMatches(row) {
20518          if (rowSelfMatches(row)) return true;
20519          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20520        }
20521
20522        function resetViewState() {
20523          activeFilter = "all";
20524          activeLanguage = "";
20525          searchTerm = "";
20526          currentSortKey = null;
20527          currentSortOrder = "asc";
20528          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20529          if (searchInput) searchInput.value = "";
20530          if (filterSelect) filterSelect.value = "all";
20531          updateLanguageButtons();
20532        }
20533
20534        function applyVisibility() {
20535          rows.forEach(function (row) {
20536            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20537            row.classList.toggle("hidden-by-filter", !visible);
20538            row.style.display = visible ? "grid" : "none";
20539          });
20540          buttons.forEach(function (button) {
20541            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20542          });
20543          if (filterSelect) filterSelect.value = activeFilter;
20544        }
20545
20546        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20547        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20548        var originalStats = {};
20549        buttons.forEach(function (btn) {
20550          var f = btn.getAttribute('data-filter');
20551          var v = btn.querySelector('.scope-stat-value');
20552          if (f && v) originalStats[f] = v.textContent;
20553        });
20554
20555        function applySubmoduleStats(statsJson) {
20556          try {
20557            var s = JSON.parse(statsJson);
20558            buttons.forEach(function (btn) {
20559              var f = btn.getAttribute('data-filter');
20560              var v = btn.querySelector('.scope-stat-value');
20561              if (!v) return;
20562              if (f === 'dir') v.textContent = s.dirs;
20563              else if (f === 'file') v.textContent = s.files;
20564              else if (f === 'supported') v.textContent = s.supported;
20565              else if (f === 'skipped') v.textContent = s.skipped;
20566              else if (f === 'unsupported') v.textContent = s.unsupported;
20567            });
20568          } catch (e) {}
20569        }
20570
20571        function restoreBaseRepoStats() {
20572          buttons.forEach(function (btn) {
20573            var f = btn.getAttribute('data-filter');
20574            var v = btn.querySelector('.scope-stat-value');
20575            if (v && originalStats[f]) v.textContent = originalStats[f];
20576          });
20577          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20578          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20579        }
20580
20581        submoduleChips.forEach(function (chip) {
20582          chip.addEventListener('click', function () {
20583            var statsJson = chip.getAttribute('data-sub-stats');
20584            if (!statsJson) return;
20585            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20586            chip.classList.add('active');
20587            applySubmoduleStats(statsJson);
20588            if (baseRepoBtn) baseRepoBtn.style.display = '';
20589          });
20590        });
20591
20592        if (baseRepoBtn) {
20593          baseRepoBtn.addEventListener('click', function () {
20594            restoreBaseRepoStats();
20595            resetViewState();
20596            sortSiblingRows();
20597            applyVisibility();
20598          });
20599        }
20600
20601        buttons.forEach(function (button) {
20602          button.addEventListener("click", function () {
20603            var filterValue = button.getAttribute("data-filter") || "all";
20604            if (filterValue === "reset-view") {
20605              restoreBaseRepoStats();
20606              resetViewState();
20607              sortSiblingRows();
20608              applyVisibility();
20609              return;
20610            }
20611            activeFilter = filterValue;
20612            applyVisibility();
20613          });
20614        });
20615
20616        rows.forEach(function (row) {
20617          updateToggleGlyph(row);
20618          var toggle = row.querySelector(".tree-toggle");
20619          if (toggle) {
20620            toggle.addEventListener("click", function () {
20621              var expanded = row.getAttribute("data-expanded") !== "false";
20622              row.setAttribute("data-expanded", expanded ? "false" : "true");
20623              updateToggleGlyph(row);
20624              applyVisibility();
20625            });
20626          }
20627        });
20628
20629        actionButtons.forEach(function (button) {
20630          button.addEventListener("click", function () {
20631            var action = button.getAttribute("data-explorer-action");
20632            if (action === "expand-all") {
20633              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20634            } else if (action === "collapse-all") {
20635              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20636            } else if (action === "clear-filters") {
20637              resetViewState();
20638            }
20639            sortSiblingRows();
20640            applyVisibility();
20641          });
20642        });
20643
20644        if (filterSelect) {
20645          filterSelect.addEventListener("change", function () {
20646            activeFilter = filterSelect.value || "all";
20647            applyVisibility();
20648          });
20649        }
20650
20651        languageButtons.forEach(function (button) {
20652          button.addEventListener("click", function () {
20653            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20654            updateLanguageButtons();
20655            applyVisibility();
20656          });
20657        });
20658
20659        sortButtons.forEach(function (button) {
20660          button.addEventListener("click", function () {
20661            var sortKey = button.getAttribute("data-sort-key");
20662            if (currentSortKey === sortKey) {
20663              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20664            } else {
20665              currentSortKey = sortKey;
20666              currentSortOrder = "asc";
20667            }
20668            sortSiblingRows();
20669            applyVisibility();
20670          });
20671        });
20672
20673        if (searchInput) {
20674          searchInput.addEventListener("input", function () {
20675            searchTerm = searchInput.value.trim().toLowerCase();
20676            applyVisibility();
20677          });
20678        }
20679
20680        updateLanguageButtons();
20681        sortSiblingRows();
20682        applyVisibility();
20683      }
20684
20685      function loadPreview() {
20686        if (!previewPanel || !pathInput) return;
20687        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20688        multiRepoBlocked = false;
20689        refreshStep1Gate();
20690        if (GIT_MODE) {
20691          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>';
20692          setPreviewLoading(false);
20693          return;
20694        }
20695        var path = pathInput.value.trim();
20696        var zeroWarn = document.getElementById('zero-files-warning');
20697        if (!path) {
20698          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20699          if (zeroWarn) zeroWarn.style.display = 'none';
20700          setPreviewLoading(false);
20701          return;
20702        }
20703        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20704        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20705        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20706        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20707        var myGen = ++_previewGen;
20708        var _prevMsgs = [
20709          'Scanning directory structure\u2026',
20710          'Detecting file types\u2026',
20711          'Applying include / exclude filters\u2026',
20712          'Estimating file counts\u2026',
20713          'Building scope preview\u2026',
20714          'Almost there\u2026'
20715        ];
20716        var _prevMsgIdx = 0;
20717        var _prevStart = Date.now();
20718        previewPanel.innerHTML =
20719          '<div class="preview-loading">' +
20720          '<div class="preview-spinner"></div>' +
20721          '<div class="preview-loading-text">' +
20722          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20723          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20724          '</div></div>';
20725        var _sizeTextEl = document.getElementById('project-size-text');
20726        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20727        window._previewInterval = setInterval(function() {
20728          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20729          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20730          var ml = document.getElementById('plm');
20731          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20732        }, 1500);
20733        window._previewElapsedTimer = setInterval(function() {
20734          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20735          var el = document.getElementById('ple');
20736          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20737        }, 1000);
20738        setPreviewLoading(true);
20739        var previewUrl = "/preview?path=" + encodeURIComponent(path)
20740          + "&include_globs=" + encodeURIComponent(includeValue)
20741          + "&exclude_globs=" + encodeURIComponent(excludeValue);
20742        fetch(previewUrl)
20743          .then(function (response) { return response.text(); })
20744          .then(function (html) {
20745            if (myGen !== _previewGen) return;
20746            clearInterval(window._previewInterval); window._previewInterval = null;
20747            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20748            setPreviewLoading(false);
20749            previewPanel.innerHTML = html;
20750            attachPreviewInteractions();
20751            syncPythonVisibility();
20752            updateReview();
20753            setTimeout(collapseLanguagePills, 50);
20754            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20755            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20756            var sizeText = document.getElementById('project-size-text');
20757            var sizeBtn = document.getElementById('project-size-btn');
20758            // In server mode with upload sizes available, keep the compressed/original pair.
20759            if (SERVER_MODE && window._lastUploadSizes) {
20760              var us = window._lastUploadSizes;
20761              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20762                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20763              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20764                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20765            } else if (sizeText && projectSize) {
20766              sizeText.textContent = 'Project size: ' + projectSize;
20767              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20768            } else if (sizeText) {
20769              sizeText.textContent = 'Project size: \u2014';
20770            }
20771            if (zeroWarn) {
20772              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20773              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20774              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20775              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20776              if (supportedCount === 0 && fileCount > 0) {
20777                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).';
20778                zeroWarn.style.display = '';
20779              } else {
20780                zeroWarn.style.display = 'none';
20781              }
20782            }
20783          })
20784          .catch(function (err) {
20785            if (myGen !== _previewGen) return;
20786            clearInterval(window._previewInterval); window._previewInterval = null;
20787            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20788            setPreviewLoading(false);
20789            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20790          });
20791      }
20792
20793      function pickDirectory(targetInput, kind) {
20794        if (!targetInput) {
20795          showBannerToast("Directory picker: input element not found.", true);
20796          return;
20797        }
20798        if (SERVER_MODE) {
20799          if (kind === 'output') {
20800            showBannerToast(
20801              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20802              false,
20803              { top: true, icon: '\u{1F4C1}' }
20804            );
20805            return;
20806          }
20807          var inputEl = kind === 'coverage'
20808            ? document.getElementById('cov-upload-input')
20809            : document.getElementById('dir-upload-input');
20810          if (!inputEl) return;
20811          inputEl.onchange = function () {
20812            var files = inputEl.files;
20813            if (!files || files.length === 0) return;
20814            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20815            if (browseBtn) browseBtn.disabled = true;
20816
20817            function fileToBase64(file) {
20818              return new Promise(function (resolve, reject) {
20819                var reader = new FileReader();
20820                reader.onload = function () {
20821                  var b64 = reader.result.split(',')[1];
20822                  resolve(b64);
20823                };
20824                reader.onerror = reject;
20825                reader.readAsDataURL(file);
20826              });
20827            }
20828
20829            if (kind === 'coverage') {
20830              var f = files[0];
20831              if (previewPanel && targetInput === pathInput)
20832                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20833              fileToBase64(f).then(function (b64) {
20834                return fetch('/api/upload-file', {
20835                  method: 'POST',
20836                  headers: { 'Content-Type': 'application/json' },
20837                  body: JSON.stringify({ filename: f.name, content: b64 })
20838                }).then(function (r) { return r.json(); });
20839              })
20840                .then(function (d) {
20841                  if (d && d.tmp_path) {
20842                    if (coverageInput) coverageInput.value = d.tmp_path;
20843                    setCovStatus('idle');
20844                  } else if (d && d.error) { showBannerToast(d.error, true); }
20845                })
20846                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20847                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20848            } else {
20849              // ── Filter to source-code files only ─────────────────────────
20850              // Binary, generated, and dependency files (node_modules, .git,
20851              // build artifacts) are skipped so they are never uploaded.
20852              var CODE_EXTS = new Set([
20853                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20854                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20855                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20856                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20857                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20858                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20859                'tf','hcl','proto','thrift','avsc','graphql','gql'
20860              ]);
20861              var codeFiles = [];
20862              for (var i = 0; i < files.length; i++) {
20863                var f = files[i];
20864                var name = f.name;
20865                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20866                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20867                  codeFiles.push(f); continue;
20868                }
20869                var dot = name.lastIndexOf('.');
20870                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20871              }
20872              // Collect specific .git metadata files for server-side git detection.
20873              // These have no source extension so they are excluded by the loop above,
20874              // but the server needs them to read branch/commit/author without running git.
20875              var gitMetaFiles = [];
20876              for (var i = 0; i < files.length; i++) {
20877                var f = files[i];
20878                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20879                var gitIdx = rp.indexOf('/.git/');
20880                if (gitIdx < 0) continue;
20881                var gitRel = rp.slice(gitIdx + 1);
20882                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20883                    gitRel === '.git/logs/HEAD' ||
20884                    gitRel.startsWith('.git/refs/heads/') ||
20885                    gitRel.startsWith('.git/refs/tags/')) {
20886                  gitMetaFiles.push(f);
20887                }
20888              }
20889              var uploadFiles = codeFiles.concat(gitMetaFiles);
20890              var total = files.length;
20891              var kept = codeFiles.length;
20892              if (kept === 0) {
20893                if (previewPanel && targetInput === pathInput)
20894                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20895                if (browseBtn) browseBtn.disabled = false;
20896                inputEl.value = '';
20897                return;
20898              }
20899
20900              // ── Helper: apply upload result to UI ────────────────────────
20901              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20902              function applyUploadResult(tmpPath, sizes) {
20903                targetInput.value = tmpPath;
20904                scrollInputToEnd(targetInput);
20905                if (sizes && SERVER_MODE) {
20906                  window._lastUploadSizes = sizes;
20907                  // Immediately show both sizes before preview loads.
20908                  var sizeText = document.getElementById('project-size-text');
20909                  var sizeBtn = document.getElementById('project-size-btn');
20910                  if (sizeText) {
20911                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20912                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20913                  }
20914                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20915                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20916                }
20917                if (targetInput === pathInput) {
20918                  updateReportTitleFromPath();
20919                  autoSetOutputDir(tmpPath);
20920                  fetchProjectHistory(tmpPath);
20921                  loadPreview();
20922                  suggestCoverageFile(tmpPath);
20923                }
20924                updateReview();
20925                if (browseBtn) browseBtn.disabled = false;
20926                inputEl.value = '';
20927              }
20928
20929              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20930              if (typeof CompressionStream !== 'undefined') {
20931                if (previewPanel && targetInput === pathInput)
20932                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20933
20934                // Build a minimal POSIX ustar tar header for a single file entry.
20935                function buildUstarHeader(filePath, fileSize) {
20936                  var BLOCK = 512;
20937                  var hdr = new Uint8Array(BLOCK);
20938                  var enc = new TextEncoder();
20939                  function wStr(off, len, s) {
20940                    var b = enc.encode(s);
20941                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20942                  }
20943                  function wOct(off, len, val) {
20944                    var s = val.toString(8);
20945                    while (s.length < len - 1) s = '0' + s;
20946                    wStr(off, len, s + '\0');
20947                  }
20948                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20949                  var name = filePath, prefix = '';
20950                  if (filePath.length > 99) {
20951                    var split = filePath.lastIndexOf('/', 154);
20952                    if (split > 0 && filePath.length - split - 1 <= 99) {
20953                      prefix = filePath.substring(0, split);
20954                      name   = filePath.substring(split + 1);
20955                    } else { name = filePath.substring(0, 99); }
20956                  }
20957                  wStr(0,   100, name);          // name
20958                  wOct(100,   8, 0o000644);      // mode
20959                  wOct(108,   8, 0);             // uid
20960                  wOct(116,   8, 0);             // gid
20961                  wOct(124,  12, fileSize);      // size
20962                  wOct(136,  12, 0);             // mtime (epoch)
20963                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20964                  hdr[156] = 48;                 // type flag '0' = regular file
20965                  wStr(157, 100, '');            // linkname
20966                  wStr(257,   6, 'ustar');       // magic
20967                  wStr(263,   2, '00');          // version
20968                  wStr(265,  32, '');            // uname
20969                  wStr(297,  32, '');            // gname
20970                  wOct(329,   8, 0);             // devmajor
20971                  wOct(337,   8, 0);             // devminor
20972                  wStr(345, 155, prefix);        // prefix
20973                  // Compute checksum (sum of all bytes, placeholder = 32).
20974                  var chk = 0;
20975                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20976                  var cs = chk.toString(8);
20977                  while (cs.length < 6) cs = '0' + cs;
20978                  wStr(148, 8, cs + '\0 ');
20979                  return hdr;
20980                }
20981
20982                // Build tar.gz one file at a time, piping through CompressionStream.
20983                // RAM usage = compressed output buffer + one file at a time.
20984                (async function () {
20985                  try {
20986                    var BLOCK = 512;
20987                    var cs     = new CompressionStream('gzip');
20988                    var writer = cs.writable.getWriter();
20989                    var chunks = [];
20990                    var reader = cs.readable.getReader();
20991                    var collecting = (async function () {
20992                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20993                    })();
20994
20995                    for (var i = 0; i < uploadFiles.length; i++) {
20996                      var file = uploadFiles[i];
20997                      var path = file.webkitRelativePath || file.name;
20998                      var buf  = await file.arrayBuffer();
20999                      var data = new Uint8Array(buf);
21000                      // Header block
21001                      await writer.write(buildUstarHeader(path, data.length));
21002                      // Data padded to 512-byte boundary
21003                      if (data.length > 0) {
21004                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21005                        var block  = new Uint8Array(padded);
21006                        block.set(data);
21007                        await writer.write(block);
21008                      }
21009                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21010                        if (previewPanel && targetInput === pathInput)
21011                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21012                      }
21013                    }
21014                    // End-of-archive: two 512-byte zero blocks
21015                    await writer.write(new Uint8Array(BLOCK * 2));
21016                    await writer.close();
21017                    await collecting;
21018
21019                    var blob = new Blob(chunks, { type: 'application/gzip' });
21020                    var sizeMB = (blob.size / 1048576).toFixed(1);
21021                    if (previewPanel && targetInput === pathInput)
21022                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21023
21024                    var resp = await fetch('/api/upload-tarball', {
21025                      method: 'POST',
21026                      headers: { 'Content-Type': 'application/gzip' },
21027                      body: blob
21028                    });
21029                    var d = await resp.json();
21030                    if (d && d.tmp_path) {
21031                      applyUploadResult(d.tmp_path, {
21032                        compressed_bytes: d.compressed_bytes || 0,
21033                        original_bytes: d.original_bytes || 0
21034                      });
21035                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21036                  } catch (e) {
21037                    showBannerToast('Upload failed: ' + String(e), true);
21038                    if (browseBtn) browseBtn.disabled = false;
21039                    inputEl.value = '';
21040                  }
21041                })();
21042
21043              } else {
21044                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21045                // Used only on browsers that lack CompressionStream (pre-2023).
21046                var BATCH = 200;
21047                var batches = [];
21048                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21049                var totalBatches = batches.length;
21050                if (previewPanel && targetInput === pathInput)
21051                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21052
21053                function sendBatch(idx, currentUploadId, lastTmpPath) {
21054                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21055                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
21056                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21057                  Promise.all(batches[idx].map(function (file) {
21058                    return fileToBase64(file).then(function (b64) {
21059                      return { path: file.webkitRelativePath || file.name, content: b64 };
21060                    });
21061                  })).then(function (fileList) {
21062                    var body = { files: fileList };
21063                    if (currentUploadId) body.upload_id = currentUploadId;
21064                    return fetch('/api/upload-directory', {
21065                      method: 'POST', headers: { 'Content-Type': 'application/json' },
21066                      body: JSON.stringify(body)
21067                    }).then(function (r) { return r.json(); });
21068                  }).then(function (d) {
21069                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21070                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21071                  }).catch(function (e) {
21072                    showBannerToast('Upload failed: ' + String(e), true);
21073                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21074                  });
21075                }
21076                sendBatch(0, null, '');
21077              }
21078            }
21079          };
21080          inputEl.click();
21081          return;
21082        }
21083
21084        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21085        if (browseButton) browseButton.disabled = true;
21086
21087        if (previewPanel && targetInput === pathInput) {
21088          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21089        }
21090
21091        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
21092          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21093          .then(function (data) {
21094            if (data && data.selected_path) {
21095              targetInput.value = data.selected_path;
21096              scrollInputToEnd(targetInput);
21097
21098              if (targetInput === pathInput) {
21099                updateReportTitleFromPath();
21100                autoSetOutputDir(data.selected_path);
21101                fetchProjectHistory(data.selected_path);
21102                loadPreview();
21103                suggestCoverageFile(data.selected_path);
21104              }
21105
21106              updateReview();
21107            } else if (targetInput === pathInput) {
21108              loadPreview();
21109            }
21110          })
21111          .catch(function () {
21112            window.alert("Directory picker request failed.");
21113            if (previewPanel && targetInput === pathInput) {
21114              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21115            }
21116          })
21117          .finally(function () {
21118            if (browseButton) browseButton.disabled = false;
21119          });
21120      }
21121
21122      if (themeToggle) {
21123        themeToggle.addEventListener("click", function () {
21124          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21125          applyTheme(nextTheme);
21126          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21127        });
21128      }
21129
21130      stepButtons.forEach(function (button) {
21131        button.addEventListener("click", function () {
21132          var target = Number(button.getAttribute("data-step-target"));
21133          // Block jumping forward off step 1 while the preview / upload is running
21134          // or while a multi-repository selection is unacknowledged.
21135          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21136          setStep(target);
21137        });
21138      });
21139
21140      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21141        button.addEventListener("click", function () {
21142          var target = Number(button.getAttribute("data-step-target")) || 1;
21143          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21144          setStep(target);
21145        });
21146      });
21147
21148      // True when the project path is untouched from the bundled sample default.
21149      function isDefaultSamplePath() {
21150        return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21151      }
21152
21153      var defaultPathOverlay = document.getElementById("default-path-overlay");
21154      function closeDefaultPathModal() {
21155        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21156      }
21157      function openDefaultPathModal() {
21158        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21159      }
21160
21161      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21162        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21163        // that borrow the .next-step style class but carry no data-next target).
21164        if (!button.hasAttribute("data-next")) return;
21165        button.addEventListener("click", function () {
21166          // Guard step 1 → 2: block while the scope preview / upload is still running
21167          // or while a multi-repository selection is unacknowledged.
21168          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21169          // Guard step 1 → 2: warn when the project path is still the sample default.
21170          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21171            openDefaultPathModal();
21172            return;
21173          }
21174          updateReview();
21175          setStep(Number(button.getAttribute("data-next")));
21176        });
21177      });
21178
21179      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21180        if (!button.hasAttribute("data-prev")) return;
21181        button.addEventListener("click", function () {
21182          setStep(Number(button.getAttribute("data-prev")));
21183        });
21184      });
21185
21186      // Default-sample-path confirmation modal wiring.
21187      var defaultPathProceed = document.getElementById("default-path-proceed");
21188      if (defaultPathProceed) {
21189        defaultPathProceed.addEventListener("click", function () {
21190          closeDefaultPathModal();
21191          updateReview();
21192          setStep(2);
21193        });
21194      }
21195      var defaultPathCancel = document.getElementById("default-path-cancel");
21196      if (defaultPathCancel) {
21197        defaultPathCancel.addEventListener("click", function () {
21198          closeDefaultPathModal();
21199          if (pathInput) { pathInput.focus(); pathInput.select(); }
21200        });
21201      }
21202      if (defaultPathOverlay) {
21203        defaultPathOverlay.addEventListener("click", function (e) {
21204          if (e.target === defaultPathOverlay) closeDefaultPathModal();
21205        });
21206      }
21207      document.addEventListener("keydown", function (e) {
21208        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21209          closeDefaultPathModal();
21210        }
21211      });
21212
21213      document.addEventListener("keydown", function (e) {
21214        var tag = (document.activeElement || {}).tagName || "";
21215        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21216        if (e.altKey || e.ctrlKey || e.metaKey) return;
21217        if (e.key === "ArrowRight" && currentStep < 4) {
21218          if (currentStep === 1 && step1ForwardBlocked()) return;
21219          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21220          updateReview(); setStep(currentStep + 1);
21221        }
21222        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21223      });
21224
21225      if (useSamplePath) {
21226        useSamplePath.addEventListener("click", function () {
21227          pathInput.value = "testing/fixtures/basic";
21228          updateReportTitleFromPath();
21229          autoSetOutputDir("testing/fixtures/basic");
21230          loadPreview();
21231          suggestCoverageFile("testing/fixtures/basic");
21232        });
21233      }
21234
21235      if (useDefaultOutput) {
21236        useDefaultOutput.addEventListener("click", function () {
21237          delete outputDirInput.dataset.userEdited;
21238          autoSetOutputDir(pathInput ? pathInput.value : "");
21239          updateReview();
21240        });
21241      }
21242
21243      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21244      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21245
21246      // ── Drag-and-drop directory upload (server mode only) ─────────────────
21247      // Dropping a folder onto the path field bypasses Chrome's
21248      // "Upload X files to this site?" confirmation dialog.
21249      async function readDirRecursively(dirEntry, basePath) {
21250        var reader = dirEntry.createReader();
21251        var all = [];
21252        for (;;) {
21253          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21254          if (!batch.length) break;
21255          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21256        }
21257        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21258        var out = [];
21259        for (var i = 0; i < all.length; i++) {
21260          var sub = all[i];
21261          if (sub.isFile) {
21262            var f = await new Promise(function(res) { sub.file(res); });
21263            out.push({ file: f, path: basePath + '/' + sub.name });
21264          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21265            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21266            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21267          }
21268        }
21269        return out;
21270      }
21271
21272      function setupPathDropZone() {
21273        if (!SERVER_MODE || !pathInput) return;
21274        var CODE_EXTS = new Set([
21275          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21276          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21277          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21278          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21279          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21280          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21281        ]);
21282        pathInput.addEventListener('dragover', function(e) {
21283          e.preventDefault();
21284          pathInput.classList.add('drag-over');
21285        });
21286        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21287        pathInput.addEventListener('drop', function(e) {
21288          e.preventDefault();
21289          pathInput.classList.remove('drag-over');
21290          var items = e.dataTransfer.items;
21291          if (!items || !items.length) return;
21292          var dirEntry = null;
21293          for (var i = 0; i < items.length; i++) {
21294            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21295            if (entry && entry.isDirectory) { dirEntry = entry; break; }
21296          }
21297          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21298          var btn = browsePath;
21299          if (btn) btn.disabled = true;
21300          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21301
21302          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21303            var total = allEntries.length;
21304            var codeEntries = allEntries.filter(function(e) {
21305              var n = e.file.name;
21306              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21307              var dot = n.lastIndexOf('.');
21308              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21309            });
21310            var kept = codeEntries.length;
21311            if (kept === 0) {
21312              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21313              if (btn) btn.disabled = false; return;
21314            }
21315
21316            function finish(tmpPath, sizes) {
21317              pathInput.value = tmpPath;
21318              scrollInputToEnd(pathInput);
21319              if (sizes) {
21320                window._lastUploadSizes = sizes;
21321                var sizeText = document.getElementById('project-size-text');
21322                var sizeBtn = document.getElementById('project-size-btn');
21323                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21324                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21325                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21326                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21327              }
21328              updateReportTitleFromPath();
21329              autoSetOutputDir(tmpPath);
21330              fetchProjectHistory(tmpPath);
21331              loadPreview();
21332              suggestCoverageFile(tmpPath);
21333              updateReview();
21334              if (btn) btn.disabled = false;
21335            }
21336
21337            if (typeof CompressionStream === 'undefined') {
21338              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21339              if (btn) btn.disabled = false; return;
21340            }
21341
21342            try {
21343              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21344              var BLOCK = 512;
21345              var cs = new CompressionStream('gzip');
21346              var wtr = cs.writable.getWriter();
21347              var chunks = [];
21348              var rdr = cs.readable.getReader();
21349              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21350
21351              function buildHdr(fp, sz) {
21352                var hdr = new Uint8Array(BLOCK);
21353                var enc = new TextEncoder();
21354                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]; }
21355                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21356                var nm = fp, pfx = '';
21357                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); } }
21358                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21359                for (var i = 148; i < 156; i++) hdr[i] = 32;
21360                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);
21361                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21362                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21363                return hdr;
21364              }
21365
21366              for (var i = 0; i < codeEntries.length; i++) {
21367                var ce = codeEntries[i];
21368                var buf = await ce.file.arrayBuffer();
21369                var data = new Uint8Array(buf);
21370                await wtr.write(buildHdr(ce.path, data.length));
21371                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21372                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21373                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21374              }
21375              await wtr.write(new Uint8Array(BLOCK * 2));
21376              await wtr.close();
21377              await collecting;
21378
21379              var blob = new Blob(chunks, { type: 'application/gzip' });
21380              var sizeMB = (blob.size / 1048576).toFixed(1);
21381              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21382              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21383              var d = await resp.json();
21384              if (d && d.tmp_path) {
21385                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21386              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21387            } catch (err) {
21388              showBannerToast('Upload failed: ' + String(err), true);
21389              if (btn) btn.disabled = false;
21390            }
21391          }).catch(function(err) {
21392            showBannerToast('Could not read folder: ' + String(err), true);
21393            if (btn) btn.disabled = false;
21394          });
21395        });
21396      }
21397      setupPathDropZone();
21398      if (browseCoverage) {
21399        browseCoverage.addEventListener("click", function () {
21400          pickDirectory(coverageInput || pathInput, "coverage");
21401        });
21402      }
21403
21404      function setCovStatus(state, opts) {
21405        if (!covScanStatus) return;
21406        opts = opts || {};
21407        covScanStatus.className = "cov-scan-status cov-scan-" + state;
21408        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21409        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>';
21410        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>';
21411        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>';
21412        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>';
21413        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21414        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21415        if (state === "scanning") {
21416          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21417        } else if (state === "found") {
21418          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21419          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21420          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21421          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21422        } else if (state === "hint") {
21423          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21424          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
21425          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>';
21426        } else if (state === "none") {
21427          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21428          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
21429        }
21430        html += '</div></div>';
21431        covScanStatus.innerHTML = html;
21432        if (state === "found") {
21433          var useBtn = covScanStatus.querySelector(".cov-scan-use");
21434          if (useBtn) useBtn.addEventListener("click", function () {
21435            if (coverageInput) coverageInput.value = "";
21436            covAutoFilled = false;
21437            setCovStatus("idle");
21438          });
21439        }
21440      }
21441
21442      function suggestCoverageFile(projectPath) {
21443        if (!coverageInput || !covScanStatus) return;
21444        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21445        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21446        clearTimeout(coverageSuggestTimer);
21447        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21448        setCovStatus("scanning");
21449        coverageSuggestTimer = setTimeout(function () {
21450          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21451            .then(function (r) { return r.json(); })
21452            .then(function (d) {
21453              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21454              if (!d) { setCovStatus("none"); return; }
21455              if (d.found) {
21456                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21457                setCovStatus("found", { found: d.found, tool: d.tool });
21458              } else if (d.tool && d.hint) {
21459                setCovStatus("hint", { tool: d.tool, hint: d.hint });
21460              } else {
21461                setCovStatus("none");
21462              }
21463            })
21464            .catch(function () { setCovStatus("idle"); });
21465        }, 600);
21466      }
21467
21468      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21469
21470      if (coverageInput) coverageInput.addEventListener("input", function () {
21471        covAutoFilled = false;
21472        if (!this.value.trim()) setCovStatus("idle");
21473      });
21474
21475      // ── Language pill overflow: collapse to "+N more" chip ─────────────
21476      function collapseLanguagePills() {
21477        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21478        rows.forEach(function(row) {
21479          // Remove any previous overflow chip
21480          var prev = row.querySelector('.lang-overflow-chip');
21481          if (prev) prev.remove();
21482          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21483          pills.forEach(function(p) { p.style.display = ''; });
21484          if (!pills.length) return;
21485
21486          // Measure after restoring all pills
21487          var containerRight = row.getBoundingClientRect().right;
21488          var hidden = [];
21489          for (var i = pills.length - 1; i >= 1; i--) {
21490            var rect = pills[i].getBoundingClientRect();
21491            if (rect.right > containerRight + 2) {
21492              hidden.unshift(pills[i]);
21493              pills[i].style.display = 'none';
21494            } else {
21495              break;
21496            }
21497          }
21498
21499          if (hidden.length) {
21500            var chip = document.createElement('button');
21501            chip.type = 'button';
21502            chip.className = 'language-pill lang-overflow-chip';
21503            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21504            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21505            row.appendChild(chip);
21506          }
21507        });
21508      }
21509
21510      // Run after preview loads (preview panel populates language pills)
21511      var _origLoadPreviewCb = window.__previewLoaded;
21512      document.addEventListener('previewLoaded', collapseLanguagePills);
21513      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21514      setTimeout(collapseLanguagePills, 400);
21515
21516      // ── Project history & output dir auto-set ──────────────────────────
21517      var wsOutputRoot   = document.getElementById("ws-output-root");
21518      var wsScanCount    = document.getElementById("ws-scan-count");
21519      var wsLastScan     = document.getElementById("ws-last-scan");
21520      var historyBadge   = document.getElementById("path-history-badge");
21521      var historyTimer   = null;
21522
21523      var wsOutputLink = document.getElementById("ws-output-link");
21524      function syncStripOutputRoot() {
21525        var val = outputDirInput ? outputDirInput.value : "";
21526        var display = val || "project/sloc";
21527        if (wsOutputRoot) wsOutputRoot.textContent = display;
21528        if (wsOutputLink) wsOutputLink.dataset.folder = val;
21529      }
21530
21531      function scrollInputToEnd(input) {
21532        if (!input) return;
21533        // Defer so the DOM has the new value before we measure scroll width.
21534        requestAnimationFrame(function () {
21535          input.scrollLeft = input.scrollWidth;
21536          input.selectionStart = input.selectionEnd = input.value.length;
21537        });
21538      }
21539
21540      function autoSetOutputDir(projectPath) {
21541        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21542        if (GIT_MODE && GIT_OUTPUT_DIR) {
21543          outputDirInput.value = GIT_OUTPUT_DIR;
21544          scrollInputToEnd(outputDirInput);
21545          syncStripOutputRoot();
21546          updateReview();
21547          return;
21548        }
21549        if (!projectPath || !projectPath.trim()) return;
21550        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21551        outputDirInput.value = cleaned + "/sloc";
21552        scrollInputToEnd(outputDirInput);
21553        syncStripOutputRoot();
21554        updateReview();
21555      }
21556
21557      var wsBranch = document.getElementById("ws-branch");
21558
21559      function fetchProjectHistory(projectPath) {
21560        if (!projectPath || !projectPath.trim()) {
21561          if (wsScanCount) wsScanCount.textContent = "\u2014";
21562          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
21563          if (wsBranch)    wsBranch.textContent    = "\u2014";
21564          if (historyBadge) historyBadge.style.display = "none";
21565          return;
21566        }
21567        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21568          .then(function (r) { return r.ok ? r.json() : null; })
21569          .then(function (data) {
21570            if (!data) return;
21571            var countStr = data.scan_count > 0
21572              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21573              : "never";
21574            var tsStr = data.last_scan_timestamp
21575              ? data.last_scan_timestamp.replace(" UTC","")
21576              : "\u2014";
21577            if (wsScanCount) wsScanCount.textContent = countStr;
21578            if (wsLastScan)  wsLastScan.textContent  = tsStr;
21579            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
21580            if (data.scan_count > 0) {
21581              if (historyBadge) {
21582                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21583                historyBadge.textContent = data.scan_count + " previous scan" +
21584                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21585                  "Last: " + (data.last_scan_timestamp || "\u2014") +
21586                  " \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.";
21587                historyBadge.className = "path-history-badge found";
21588                historyBadge.style.display = "";
21589              }
21590            } else {
21591              if (historyBadge) historyBadge.style.display = "none";
21592            }
21593          })
21594          .catch(function () {});
21595      }
21596
21597      function onPathChange() {
21598        var val = pathInput ? pathInput.value : "";
21599        // Discard stale upload sizes when the user edits the path manually.
21600        window._lastUploadSizes = null;
21601        updateReportTitleFromPath();
21602        autoSetOutputDir(val);
21603        updateSidebarSummary();
21604        clearTimeout(historyTimer);
21605        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21606        if (previewTimer) clearTimeout(previewTimer);
21607        previewTimer = setTimeout(loadPreview, 280);
21608        suggestCoverageFile(val);
21609      }
21610
21611      if (pathInput) {
21612        pathInput.addEventListener("input", onPathChange);
21613      }
21614
21615      if (outputDirInput) {
21616        outputDirInput.addEventListener("input", function () {
21617          outputDirInput.dataset.userEdited = "1";
21618          syncStripOutputRoot();
21619          updateReview();
21620        });
21621      }
21622
21623      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21624        if (!node) return;
21625        node.addEventListener("input", function () {
21626          updateReview();
21627          if (previewTimer) clearTimeout(previewTimer);
21628          previewTimer = setTimeout(loadPreview, 280);
21629        });
21630      });
21631
21632      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21633        var node = document.getElementById(id);
21634        if (node) node.addEventListener("change", updateReview);
21635      });
21636
21637      if (reportTitleInput) {
21638        reportTitleInput.addEventListener("input", function () {
21639          reportTitleTouched = reportTitleInput.value.trim().length > 0;
21640          updateReportTitleFromPath();
21641          updateReview();
21642        });
21643      }
21644
21645      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21646      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21647      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21648      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21649
21650      if (coverageInput) {
21651        coverageInput.addEventListener("input", function () {
21652          if (coverageInput.value.trim()) setCovStatus("idle");
21653        });
21654      }
21655
21656      if (form && loading && submitButton) {
21657        form.addEventListener("submit", function (e) {
21658          e.preventDefault();
21659          submitButton.disabled = true;
21660          submitButton.textContent = "Scanning...";
21661          startAsyncAnalysis(new FormData(form));
21662        });
21663      }
21664
21665      function openPath(folder) {
21666        if (!folder) return;
21667        fetch('/open-path?path=' + encodeURIComponent(folder))
21668          .then(function (r) { return r.json(); })
21669          .then(function (d) {
21670            if (d && d.server_mode_disabled)
21671              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21672          })
21673          .catch(function () {});
21674      }
21675
21676      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21677        btn.addEventListener('click', function () {
21678          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21679        });
21680      });
21681
21682      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21683      if (wsOutputLink) {
21684        wsOutputLink.addEventListener('click', function () {
21685          openPath(wsOutputLink.dataset.folder || '');
21686        });
21687      }
21688
21689      loadSavedTheme();
21690      updateMixedPolicyUI();
21691      updatePythonDocstringUI();
21692      applyScanPreset();
21693      updatePresetDescriptions();
21694      applyArtifactPreset();
21695      updateReview();
21696      updateScrollProgress(); // initialise bar to 0% (step 1)
21697      window.addEventListener("scroll", updateScrollProgress, { passive: true });
21698      onPathChange();         // seed output dir, history badge, and preview from initial path
21699      updateStepNav(1);
21700
21701      // Restore step from URL hash on initial load (e.g., back-forward cache)
21702      (function() {
21703        var hashMatch = location.hash.match(/^#step([1-4])$/);
21704        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21705      })();
21706
21707      (function randomizeWatermarks() {
21708        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21709        if (!wms.length) return;
21710        var placed = [];
21711        function tooClose(top, left) {
21712          for (var i = 0; i < placed.length; i++) {
21713            var dt = Math.abs(placed[i][0] - top);
21714            var dl = Math.abs(placed[i][1] - left);
21715            if (dt < 16 && dl < 12) return true;
21716          }
21717          return false;
21718        }
21719        function pick(leftBand) {
21720          for (var attempt = 0; attempt < 50; attempt++) {
21721            var top = Math.random() * 88 + 2;
21722            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21723            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21724          }
21725          var top = Math.random() * 88 + 2;
21726          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21727          placed.push([top, left]);
21728          return [top, left];
21729        }
21730        var half = Math.floor(wms.length / 2);
21731        wms.forEach(function (img, i) {
21732          var pos = pick(i < half);
21733          var size = Math.floor(Math.random() * 80 + 110);
21734          var rot = (Math.random() * 360).toFixed(1);
21735          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21736          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;
21737        });
21738      })();
21739
21740      (function spawnCodeParticles() {
21741        var container = document.getElementById('code-particles');
21742        if (!container) return;
21743        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'];
21744        for (var i = 0; i < 38; i++) {
21745          (function(idx) {
21746            var el = document.createElement('span');
21747            el.className = 'code-particle';
21748            el.textContent = snippets[idx % snippets.length];
21749            var left = Math.random() * 94 + 2;
21750            var top = Math.random() * 88 + 6;
21751            var dur = (Math.random() * 10 + 9).toFixed(1);
21752            var delay = (Math.random() * 18).toFixed(1);
21753            var rot = (Math.random() * 26 - 13).toFixed(1);
21754            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21755            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';
21756            container.appendChild(el);
21757          })(i);
21758        }
21759      })();
21760    })();
21761  </script>
21762  <script nonce="{{ csp_nonce }}">
21763    (function () {
21764      var raw = {{ prefill_json|safe }};
21765      if (!raw || typeof raw !== 'object' || !raw.path) return;
21766      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21767      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21768      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21769      setVal('path', raw.path || '');
21770      setVal('include_globs', raw.include_globs || '');
21771      setVal('exclude_globs', raw.exclude_globs || '');
21772      setVal('output_dir', raw.output_dir || '');
21773      setVal('report_title', raw.report_title || '');
21774      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21775      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21776      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21777      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21778      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21779      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21780      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21781      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21782      setChecked('generate_html', raw.generate_html !== false);
21783      setChecked('generate_pdf', !!raw.generate_pdf);
21784      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21785      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21786      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21787      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21788      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21789      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21790      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21791      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21792      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21793      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21794      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21795      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21796      // Trigger dynamic UI updates after pre-fill.
21797      setTimeout(function () {
21798        var pathEl = document.getElementById('path');
21799        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21800        var policyEl = document.getElementById('mixed_line_policy');
21801        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21802      }, 80);
21803    })();
21804  </script>
21805  <script nonce="{{ csp_nonce }}">
21806  (function(){
21807    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'}];
21808    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);});}
21809    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21810    function init(){
21811      var btn=document.getElementById('settings-btn');if(!btn)return;
21812      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21813      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>';
21814      document.body.appendChild(m);
21815      var g=document.getElementById('scheme-grid');
21816      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);});
21817      var cl=document.getElementById('settings-close');
21818      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);});})();
21819      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');});
21820      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21821      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21822    }
21823    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21824  }());
21825  </script>
21826  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21827    <div class="wb-ftip-arrow"></div>
21828    <span id="wb-ftip-text"></span>
21829  </div>
21830  <script nonce="{{ csp_nonce }}">(function(){
21831    var tip=document.getElementById('wb-ftip');
21832    var txt=document.getElementById('wb-ftip-text');
21833    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21834    if(!tip||!txt)return;
21835    function pos(el){
21836      var r=el.getBoundingClientRect();
21837      tip.style.display='block';
21838      var tw=tip.offsetWidth;
21839      var lx=r.left+r.width/2-tw/2;
21840      if(lx<8)lx=8;
21841      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21842      tip.style.left=lx+'px';
21843      tip.style.top=(r.bottom+8)+'px';
21844      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';}
21845    }
21846    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21847      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21848      el.addEventListener('mouseleave',function(){tip.style.display='none';});
21849    });
21850    window.addEventListener('blur',function(){tip.style.display='none';});
21851    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21852  })();
21853  (function(){
21854    function fixArtifactHintSpacing(){
21855      var grid=document.querySelector('.artifact-grid');
21856      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21857    }
21858    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21859  }());
21860  (function(){
21861    var dot=document.getElementById('status-dot');
21862    var pingEl=document.getElementById('server-ping-ms');
21863    var tipEl=document.getElementById('server-tip-ping');
21864    var fm=document.getElementById('footer-mode');
21865    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)';}}
21866    function doPing(){
21867      var t0=performance.now();
21868      fetch('/healthz',{cache:'no-store'})
21869        .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);})
21870        .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)';}});
21871    }
21872    doPing();
21873    setInterval(doPing,5000);
21874    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');}
21875  })();
21876  </script>
21877  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21878  <footer class="site-footer">
21879    local code analysis - metrics, history and reports
21880    &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>
21881    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21882    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21883    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21884    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21885  </footer>
21886</body>
21887</html>
21888"##,
21889    ext = "html"
21890)]
21891struct IndexTemplate {
21892    version: &'static str,
21893    prefill_json: String,
21894    csp_nonce: String,
21895    git_repo: String,
21896    git_ref: String,
21897    git_label_json: String,
21898    git_output_dir_json: String,
21899    server_mode: bool,
21900}
21901
21902// ── SplashTemplate ────────────────────────────────────────────────────────────
21903
21904#[derive(Template)]
21905#[template(
21906    source = r##"
21907<!doctype html>
21908<html lang="en">
21909<head>
21910  <meta charset="utf-8">
21911  <meta name="viewport" content="width=device-width, initial-scale=1">
21912  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21913  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21914  <script type="application/ld+json">
21915  {
21916    "@context": "https://schema.org",
21917    "@type": "SoftwareApplication",
21918    "name": "oxide-sloc",
21919    "applicationCategory": "DeveloperApplication",
21920    "operatingSystem": "Windows, Linux",
21921    "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.",
21922    "softwareVersion": "{{ version }}",
21923    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21924    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21925    "url": "https://github.com/oxide-sloc/oxide-sloc",
21926    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21927    "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",
21928    "programmingLanguage": "Rust",
21929    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21930  }
21931  </script>
21932  <style nonce="{{ csp_nonce }}">
21933    :root {
21934      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21935      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21936      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21937      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21938      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21939    }
21940    body.dark-theme {
21941      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21942      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21943    }
21944    *{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;}
21945    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21946    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21947    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21948    .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;}
21949    @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));}}
21950    .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);}
21951    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21952    .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));}
21953    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21954    .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;}
21955    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21956    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21957    @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; } }
21958    .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;}
21959    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21960    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21961    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21962    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21963    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21964    .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;}
21965    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21966    .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);}
21967    .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;}
21968    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21969    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21970    .settings-modal-body{padding:14px 16px 16px;}
21971    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21972    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21973    .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;}
21974    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21975    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21976    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21977    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21978    .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;}
21979    .tz-select:focus{border-color:var(--oxide);}
21980    .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;}
21981    .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;}
21982    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21983    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21984    .hero{text-align:center;margin:0 auto 18px;}
21985    .hero-logo-wrap{display:inline-block;cursor:default;}
21986    .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;}
21987    .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;}
21988    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21989    .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;}
21990    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%);}
21991    .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;
21992      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21993      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21994      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;}
21995    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21996    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21997    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;}
21998    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21999    .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;}
22000    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22001    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22002    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22003    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22004    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22005    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22006    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22007    .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;}
22008    .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;}
22009    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22010    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22011    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22012    .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);}
22013    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22014    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22015    .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);}
22016    .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);}
22017    .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);}
22018    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22019    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22020    .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;}
22021    body.dark-theme .action-card-cta{color:var(--oxide);}
22022    .action-card.view .action-card-cta{color:var(--accent-2);}
22023    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22024    .action-card.compare .action-card-cta{color:#7c3aed;}
22025    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22026    .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);}
22027    .action-card.git-tools .action-card-cta{color:#15803d;}
22028    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22029    .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);}
22030    .action-card.trend .action-card-cta{color:#0e7490;}
22031    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22032    .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);}
22033    .action-card.automation .action-card-cta{color:#b45309;}
22034    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22035    .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);}
22036    .action-card.test-metrics .action-card-cta{color:#be185d;}
22037    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22038    .action-card:hover .action-card-cta{gap:12px;}
22039    .action-card.card-split{flex-direction:row;align-items:stretch;}
22040    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22041    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22042    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22043    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22044    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22045    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22046    .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;}
22047    .ac-badge.active{opacity:1;}
22048    .ac-badge.github{border-color:#555;color:#555;}
22049    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22050    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22051    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22052    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22053    body.dark-theme .ac-right-row{color:var(--muted);}
22054    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22055    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22056    .divider{height:1px;background:var(--line);margin:32px 0;}
22057    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22058    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22059    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22060    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22061      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22062    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22063    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22064    body.dark-theme .info-chip-val{color:var(--oxide);}
22065    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22066    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22067      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22068      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22069    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22070      border:6px solid transparent;border-top-color:var(--text);}
22071    .info-chip:hover .info-chip-tip{display:block;}
22072    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22073    .chip-slide.fading{filter:blur(5px);opacity:0;}
22074    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22075    .site-footer a{color:var(--muted);}
22076    .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;}
22077    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22078    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22079    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22080    .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;}
22081    .lan-badge.local{background:var(--oxide-2);}
22082    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22083    .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);}
22084    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22085    .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;}
22086    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22087    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22088    .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;}
22089    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22090    .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;}
22091    .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);}
22092    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22093    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22094    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22095    .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;}
22096    @media (max-height: 1100px) {
22097      .page{padding-top:10px;}
22098      .hero{margin-bottom:10px;}
22099      .hero-logo{width:54px;height:60px;}
22100      .hero-logo-shadow{width:42px;}
22101      .hero-title{font-size:28px;}
22102      .hero-subtitle{font-size:13px;}
22103      .card-sections{gap:12px;margin-bottom:6px;}
22104      .card-section-grid-2,.card-section-grid-3{gap:10px;}
22105      .action-card{padding:8px 15px 8px;}
22106      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22107      .action-card-icon svg{width:18px;height:18px;}
22108      .action-card-title{font-size:13px;}
22109      .action-card-desc{font-size:11px;margin-bottom:6px;}
22110      .action-card-cta{font-size:11px;}
22111      .ac-right-row{font-size:11px;}
22112      .divider{margin:14px 0;}
22113      .info-strip{gap:7px;margin-bottom:8px;}
22114      .info-chip{padding:7px 10px;}
22115      .info-chip-val{font-size:13px;}
22116      .info-chip-label{font-size:9px;}
22117      .site-footer{padding:8px 24px;font-size:12px;}
22118      .lan-local-hint{margin-top:8px;}
22119    }
22120    @media (max-height: 850px) {
22121      .page{padding-top:6px;}
22122      .hero{margin-bottom:6px;}
22123      .hero-logo{width:42px;height:46px;}
22124      .hero-title{font-size:22px;}
22125      .hero-subtitle{font-size:12px;}
22126      .card-sections{gap:10px;}
22127      .action-card-desc{margin-bottom:4px;}
22128      .divider{margin:8px 0;}
22129      .info-strip{margin-bottom:6px;}
22130      .lan-local-hint{margin-top:10px;}
22131    }
22132  </style>
22133</head>
22134<body>
22135  <div class="background-watermarks" aria-hidden="true">
22136    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22137    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22138    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22139    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22140    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22141    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22142    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22143  </div>
22144  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22145  <div class="top-nav">
22146    <div class="top-nav-inner">
22147      <a class="brand" href="/">
22148        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22149        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22150      </a>
22151      <div class="nav-right">
22152        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22153        <div class="nav-dropdown">
22154          <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>
22155          <div class="nav-dropdown-menu">
22156            <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>
22157          </div>
22158        </div>
22159        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22160        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22161        <div class="nav-dropdown">
22162          <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>
22163          <div class="nav-dropdown-menu">
22164            <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>
22165          </div>
22166        </div>
22167        <div class="server-status-wrap" id="server-status-wrap">
22168          <div class="nav-pill server-online-pill" id="server-status-pill">
22169            <span class="status-dot" id="status-dot"></span>
22170            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22171            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22172          </div>
22173          <div class="server-status-tip">
22174            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22175            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22176          </div>
22177        </div>
22178        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22179          <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>
22180        </button>
22181        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22182          <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>
22183          <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>
22184        </button>
22185      </div>
22186    </div>
22187  </div>
22188
22189  <div class="page">
22190    <div class="hero">
22191      <div class="hero-logo-wrap" id="hero-logo-wrap">
22192        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22193      </div>
22194      <div class="hero-logo-shadow"></div>
22195      <div class="hero-title-wrap">
22196        <div class="hero-title-aura" aria-hidden="true"></div>
22197        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22198      </div>
22199      <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>
22200    </div>
22201
22202    <div class="card-sections">
22203
22204      <div>
22205        <div class="card-section-label">Analysis</div>
22206        <div class="card-section-grid-2">
22207          <a class="action-card scan card-split" href="/scan-setup">
22208            <div class="action-card-left">
22209              <div class="action-card-icon">
22210                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22211              </div>
22212              <div class="action-card-title">Scan Project</div>
22213              <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>
22214              <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>
22215            </div>
22216            <div class="action-card-sep"></div>
22217            <div class="action-card-right">
22218              <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>
22219              <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>
22220              <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>
22221              <div class="ac-right-stat" id="acp-scan-stat"></div>
22222            </div>
22223          </a>
22224          <a class="action-card test-metrics card-split" href="/test-metrics">
22225            <div class="action-card-left">
22226              <div class="action-card-icon">
22227                <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>
22228              </div>
22229              <div class="action-card-title">Test Metrics</div>
22230              <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>
22231              <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>
22232            </div>
22233            <div class="action-card-sep"></div>
22234            <div class="action-card-right">
22235              <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>
22236              <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>
22237              <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>
22238              <div class="ac-right-stat" id="acp-test-stat"></div>
22239            </div>
22240          </a>
22241        </div>
22242      </div>
22243
22244      <div>
22245        <div class="card-section-label">Reports &amp; Insights</div>
22246        <div class="card-section-grid-3">
22247          <a class="action-card view" href="/view-reports">
22248            <div class="action-card-icon">
22249              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22250            </div>
22251            <div class="action-card-title">View Reports</div>
22252            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22253            <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>
22254          </a>
22255          <a class="action-card compare" href="/compare-scans">
22256            <div class="action-card-icon">
22257              <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>
22258            </div>
22259            <div class="action-card-title">Compare Scans</div>
22260            <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>
22261            <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>
22262          </a>
22263          <a class="action-card trend" href="/trend-reports">
22264            <div class="action-card-icon">
22265              <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>
22266            </div>
22267            <div class="action-card-title">Trend Report</div>
22268            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22269            <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>
22270          </a>
22271        </div>
22272      </div>
22273
22274      <div>
22275        <div class="card-section-label">Developer Tools</div>
22276        <div class="card-section-grid-2">
22277          <a class="action-card git-tools card-split" href="/git-browser">
22278            <div class="action-card-left">
22279              <div class="action-card-icon">
22280                <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>
22281              </div>
22282              <div class="action-card-title">Git Browser</div>
22283              <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>
22284              <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>
22285            </div>
22286            <div class="action-card-sep"></div>
22287            <div class="action-card-right">
22288              <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>
22289              <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>
22290              <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>
22291            </div>
22292          </a>
22293          <a class="action-card automation card-split" href="/integrations">
22294            <div class="action-card-left">
22295              <div class="action-card-icon">
22296                <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>
22297              </div>
22298              <div class="action-card-title">Integrations</div>
22299              <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>
22300              <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>
22301            </div>
22302            <div class="action-card-sep"></div>
22303            <div class="action-card-right">
22304              <div class="ac-badges-grid">
22305                <span class="ac-badge github"     id="acp-gh">GitHub</span>
22306                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
22307                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
22308                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22309              </div>
22310              <div class="ac-right-stat" id="acp-int-stat"></div>
22311            </div>
22312          </a>
22313        </div>
22314      </div>
22315
22316    </div>
22317
22318    {% if server_mode %}
22319    <div class="lan-card server">
22320      <div class="lan-card-header">
22321        <span class="lan-badge">LAN server</span>
22322        Accessible on your network
22323      </div>
22324      {% if let Some(ip) = lan_ip %}
22325      <div class="lan-url-row">
22326        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22327        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22328          <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>
22329          Copy URL
22330        </button>
22331      </div>
22332      <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>
22333      {% if has_api_key %}
22334      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
22335      {% endif %}
22336      {% else %}
22337      <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>
22338      {% endif %}
22339    </div>
22340    {% endif %}
22341
22342    <div class="divider"></div>
22343
22344    <div class="info-strip">
22345      <div class="info-chip">
22346        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22347        <div class="chip-slide">
22348          <div class="info-chip-val">60</div>
22349          <div class="info-chip-label">Languages</div>
22350        </div>
22351      </div>
22352      <div class="info-chip">
22353        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22354        <div class="chip-slide">
22355          <div class="info-chip-val">100%</div>
22356          <div class="info-chip-label">Self-contained</div>
22357        </div>
22358      </div>
22359      <div class="info-chip">
22360        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22361        <div class="chip-slide">
22362          <div class="info-chip-val">HTML+PDF</div>
22363          <div class="info-chip-label">Exportable reports</div>
22364        </div>
22365      </div>
22366      <div class="info-chip">
22367        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22368        <div class="chip-slide">
22369          <div class="info-chip-val">Webhook</div>
22370          <div class="info-chip-label">3 platforms</div>
22371        </div>
22372      </div>
22373      <div class="info-chip">
22374        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22375        <div class="chip-slide">
22376          <div class="info-chip-val">IEEE</div>
22377          <div class="info-chip-label">1045-1992</div>
22378        </div>
22379      </div>
22380    </div>
22381
22382    {% if lan_ip.is_none() %}
22383    <div class="lan-local-hint">
22384      <strong>Want teammates on the same network to access this?</strong><br>
22385      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
22386    </div>
22387    {% endif %}
22388  </div>
22389
22390  <footer class="site-footer">
22391    local code analysis - metrics, history and reports
22392    &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>
22393    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22394    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22395    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22396    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22397  </footer>
22398
22399  <script nonce="{{ csp_nonce }}">
22400    (function () {
22401      var storageKey = 'oxide-sloc-theme';
22402      var body = document.body;
22403      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22404      var toggle = document.getElementById('theme-toggle');
22405      if (toggle) toggle.addEventListener('click', function () {
22406        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22407        body.classList.toggle('dark-theme', next === 'dark');
22408        try { localStorage.setItem(storageKey, next); } catch(e) {}
22409      });
22410      var copyBtn = document.getElementById('lan-copy-btn');
22411      if (copyBtn) copyBtn.addEventListener('click', function() {
22412        var btn = this;
22413        var el = document.getElementById('lan-url-val');
22414        if (!el) return;
22415        var url = el.textContent.trim();
22416        if (navigator.clipboard) {
22417          navigator.clipboard.writeText(url).then(function() {
22418            var orig = btn.innerHTML;
22419            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!';
22420            setTimeout(function() { btn.innerHTML = orig; }, 1800);
22421          });
22422        }
22423      });
22424      (function randomizeWatermarks() {
22425        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22426        if (!wms.length) return;
22427        var placed = [];
22428        function tooClose(top, left) {
22429          for (var i = 0; i < placed.length; i++) {
22430            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22431            if (dt < 16 && dl < 12) return true;
22432          }
22433          return false;
22434        }
22435        function pick(leftBand) {
22436          for (var attempt = 0; attempt < 50; attempt++) {
22437            var top = Math.random() * 88 + 2;
22438            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22439            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22440          }
22441          var top = Math.random() * 88 + 2;
22442          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22443          placed.push([top, left]); return [top, left];
22444        }
22445        var half = Math.floor(wms.length / 2);
22446        wms.forEach(function (img, i) {
22447          var pos = pick(i < half);
22448          var size = Math.floor(Math.random() * 100 + 120);
22449          var rot = (Math.random() * 360).toFixed(1);
22450          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22451          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;
22452        });
22453      })();
22454
22455      (function spawnCodeParticles() {
22456        var container = document.getElementById('code-particles');
22457        if (!container) return;
22458        var snippets = [
22459          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22460          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22461          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22462          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22463          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22464        ];
22465        var count = 38;
22466        for (var i = 0; i < count; i++) {
22467          (function(idx) {
22468            var el = document.createElement('span');
22469            el.className = 'code-particle';
22470            var text = snippets[idx % snippets.length];
22471            el.textContent = text;
22472            var left = Math.random() * 94 + 2;
22473            var top = Math.random() * 88 + 6;
22474            var dur = (Math.random() * 10 + 9).toFixed(1);
22475            var delay = (Math.random() * 18).toFixed(1);
22476            var rot = (Math.random() * 26 - 13).toFixed(1);
22477            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22478            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22479              + '--rot:' + rot + 'deg;--op:' + op + ';'
22480              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22481            container.appendChild(el);
22482          })(i);
22483        }
22484      })();
22485      (function heroAnimations() {
22486        var sub = document.getElementById('hero-subtitle');
22487        if (sub) {
22488          var full = sub.textContent.trim();
22489          sub.textContent = '';
22490          sub.style.opacity = '1';
22491          var cursor = document.createElement('span');
22492          cursor.className = 'hero-cursor';
22493          sub.appendChild(cursor);
22494          var i = 0;
22495          setTimeout(function() {
22496            var iv = setInterval(function() {
22497              if (i < full.length) {
22498                sub.insertBefore(document.createTextNode(full[i]), cursor);
22499                i++;
22500              } else {
22501                clearInterval(iv);
22502                setTimeout(function() {
22503                  cursor.style.transition = 'opacity 1s ease';
22504                  cursor.style.opacity = '0';
22505                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22506                }, 2400);
22507              }
22508            }, 11);
22509          }, 374);
22510        }
22511      })();
22512      (function logoBob() {
22513        var logo = document.querySelector('.hero-logo');
22514        var shadow = document.querySelector('.hero-logo-shadow');
22515        if (!logo) return;
22516        var cycleStart = null, cycleDur = 3600;
22517        var peakY = -14, peakScale = 1.07, peakRot = 0;
22518        function newCycle() {
22519          cycleDur = 3000 + Math.random() * 1840;
22520          peakY = -(9 + Math.random() * 13.8);
22521          peakScale = 1.04 + Math.random() * 0.081;
22522          peakRot = (Math.random() * 11.5 - 5.75);
22523        }
22524        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22525        newCycle();
22526        function frame(ts) {
22527          if (cycleStart === null) cycleStart = ts;
22528          var t = (ts - cycleStart) / cycleDur;
22529          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22530          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22531          var y = peakY * phase;
22532          var sc = 1 + (peakScale - 1) * phase;
22533          var rot = peakRot * Math.sin(Math.PI * phase);
22534          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22535          if (shadow) {
22536            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22537            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22538          }
22539          requestAnimationFrame(frame);
22540        }
22541        requestAnimationFrame(frame);
22542      })();
22543      (function mouseEffects() {
22544        var heroTitle = document.getElementById('hero-title');
22545        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22546        function tick() {
22547          raf = null;
22548          if (heroTitle) {
22549            var r = heroTitle.getBoundingClientRect();
22550            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22551            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22552            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22553          }
22554        }
22555        document.addEventListener('mousemove', function(e) {
22556          mx = e.clientX; my = e.clientY;
22557          if (!raf) raf = requestAnimationFrame(tick);
22558        });
22559        document.addEventListener('mouseleave', function() {
22560          if (heroTitle) {
22561            heroTitle.style.transition = 'transform 0.5s ease';
22562            heroTitle.style.transform = '';
22563            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22564          }
22565        });
22566        document.querySelectorAll('.action-card').forEach(function(card) {
22567          card.addEventListener('mousemove', function(e) {
22568            var rect = card.getBoundingClientRect();
22569            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22570            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22571            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22572            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22573          });
22574          card.addEventListener('mouseleave', function() {
22575            card.style.transition = '';
22576            card.style.transform = '';
22577          });
22578        });
22579      })();
22580      (function chipSlideshow() {
22581        var slides = [
22582          [{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'}],
22583          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22584          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22585          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22586          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22587        ];
22588        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22589        var indices = [0,0,0,0,0];
22590        var paused = [false,false,false,false,false];
22591        chips.forEach(function(chip, i) {
22592          chip.addEventListener('mouseenter', function() { paused[i] = true; });
22593          chip.addEventListener('mouseleave', function() { paused[i] = false; });
22594        });
22595        function advance(i) {
22596          if (paused[i]) return;
22597          var chip = chips[i];
22598          var inner = chip.querySelector('.chip-slide');
22599          if (!inner) return;
22600          inner.classList.add('fading');
22601          setTimeout(function() {
22602            indices[i] = (indices[i] + 1) % slides[i].length;
22603            var s = slides[i][indices[i]];
22604            chip.querySelector('.info-chip-val').textContent = s.v;
22605            chip.querySelector('.info-chip-label').textContent = s.l;
22606            inner.classList.remove('fading');
22607          }, 720);
22608        }
22609        setInterval(function() {
22610          chips.forEach(function(chip, i) { advance(i); });
22611        }, 6000);
22612      })();
22613      (function cardLiveData() {
22614        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22615          var el = document.getElementById('acp-scan-stat');
22616          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22617        }).catch(function(){});
22618        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22619          var el = document.getElementById('acp-test-stat');
22620          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22621        }).catch(function(){});
22622        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22623          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22624          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22625          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22626          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22627          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22628          var stat = document.getElementById('acp-int-stat');
22629          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22630        }).catch(function(){});
22631        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22632          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22633        }).catch(function(){});
22634      })();
22635    })();
22636  </script>
22637  <script nonce="{{ csp_nonce }}">
22638  (function(){
22639    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'}];
22640    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);});}
22641    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22642    function init(){
22643      var btn=document.getElementById('settings-btn');if(!btn)return;
22644      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22645      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>';
22646      document.body.appendChild(m);
22647      var g=document.getElementById('scheme-grid');
22648      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);});
22649      var cl=document.getElementById('settings-close');
22650      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);});})();
22651      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');});
22652      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22653      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22654    }
22655    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22656  }());
22657  </script>
22658  <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>
22659</body>
22660</html>
22661"##,
22662    ext = "html"
22663)]
22664struct SplashTemplate {
22665    csp_nonce: String,
22666    server_mode: bool,
22667    lan_ip: Option<String>,
22668    port: u16,
22669    version: &'static str,
22670    has_api_key: bool,
22671}
22672
22673// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
22674
22675#[derive(Template)]
22676#[template(
22677    source = r##"
22678<!doctype html>
22679<html lang="en">
22680<head>
22681  <meta charset="utf-8">
22682  <meta name="viewport" content="width=device-width, initial-scale=1">
22683  <title>OxideSLOC — Start a Scan</title>
22684  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22685  <style nonce="{{ csp_nonce }}">
22686    :root {
22687      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22688      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22689      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22690      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22691      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22692    }
22693    body.dark-theme {
22694      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22695      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22696    }
22697    *{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;}
22698    .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);}
22699    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22700    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22701    .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));}
22702    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22703    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22704    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22705    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22706    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22707    @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; } }
22708    .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;}
22709    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22710    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22711    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22712    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22713    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22714    .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;}
22715    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22716    .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);}
22717    .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;}
22718    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22719    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22720    .settings-modal-body{padding:14px 16px 16px;}
22721    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22722    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22723    .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;}
22724    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22725    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22726    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22727    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22728    .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;}
22729    .tz-select:focus{border-color:var(--oxide);}
22730    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22731    .page-header{text-align:center;margin-bottom:16px;}
22732    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22733    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22734    /* Cards */
22735    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22736    .option-card-wrap{position:relative;}
22737    .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;}
22738    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22739    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22740    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22741    .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;}
22742    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22743    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22744    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22745    .card-top-row{display:flex;align-items:center;gap:20px;}
22746    /* Two-column layout inside each card */
22747    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22748    .card-left{display:flex;align-items:flex-start;min-width:0;}
22749    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22750    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22751    .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);}
22752    .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);}
22753    .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);}
22754    .card-text{min-width:0;}
22755    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22756    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22757    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22758    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22759    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22760    /* Right CTA column */
22761    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22762    .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;}
22763    /* Re-scan count badge */
22764    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22765    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22766    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22767    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22768    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22769    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22770    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22771    body.dark-theme .btn-secondary{color:var(--oxide);}
22772    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22773    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22774    /* File input overlay — must be full-width so it aligns with other card-right buttons */
22775    .file-input-wrap{position:relative;width:100%;}
22776    .file-input-wrap .btn{width:100%;}
22777    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22778    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22779    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22780    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22781    .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;}
22782    @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));}}
22783    /* Recent list (card 3 — full-width section below header) */
22784    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22785    .recent-list{display:flex;flex-direction:column;gap:8px;}
22786    .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;}
22787    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22788    .recent-item-info{flex:1;min-width:0;}
22789    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22790    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22791    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22792    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22793    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22794    .site-footer a{color:var(--muted);}
22795    @media(max-width:680px){
22796      .card-body{grid-template-columns:1fr;}
22797      .card-right{flex-direction:row;flex-wrap:wrap;}
22798      .btn{flex:1;}
22799    }
22800    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
22801    .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;}
22802    .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;}
22803  </style>
22804</head>
22805<body>
22806  <div class="background-watermarks" aria-hidden="true">
22807    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22808    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22809    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22810    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22811    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22812    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22813    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22814  </div>
22815  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22816  <div class="top-nav">
22817    <div class="top-nav-inner">
22818      <a class="brand" href="/">
22819        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22820        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22821      </a>
22822      <div class="nav-right">
22823        <a class="nav-pill" href="/">Home</a>
22824        <div class="nav-dropdown">
22825          <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>
22826          <div class="nav-dropdown-menu">
22827            <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>
22828          </div>
22829        </div>
22830        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22831        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22832        <div class="nav-dropdown">
22833          <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>
22834          <div class="nav-dropdown-menu">
22835            <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>
22836          </div>
22837        </div>
22838        <div class="server-status-wrap" id="server-status-wrap">
22839          <div class="nav-pill server-online-pill" id="server-status-pill">
22840            <span class="status-dot" id="status-dot"></span>
22841            <span id="server-status-label">Server</span>
22842            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22843          </div>
22844          <div class="server-status-tip">
22845            OxideSLOC is running — accessible on your network.
22846            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22847          </div>
22848        </div>
22849        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22850          <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>
22851        </button>
22852        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22853          <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>
22854          <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>
22855        </button>
22856      </div>
22857    </div>
22858  </div>
22859
22860  <div class="page">
22861    <div class="page-header">
22862      <h1>How would you like to scan?</h1>
22863      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22864    </div>
22865
22866    <div class="option-grid">
22867
22868      <!-- Option 1: New scan -->
22869      <div class="option-card-wrap">
22870        <div class="option-card">
22871        <div class="option-icon new-scan">
22872          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22873        </div>
22874        <div class="card-body">
22875          <div class="card-left">
22876            <div class="card-text">
22877              <div class="option-title">Start a new scan</div>
22878              <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>
22879              <ul class="feature-list">
22880                <li>Live project scope preview before you run</li>
22881                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22882                <li>HTML, PDF, and JSON output — your choice</li>
22883              </ul>
22884            </div>
22885          </div>
22886          <div class="card-right">
22887            <a class="btn btn-primary" href="/scan">
22888              Configure &amp; scan
22889              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22890            </a>
22891            <p class="card-tip">Full 4-step setup · all options</p>
22892          </div>
22893        </div>
22894        </div>
22895      </div>
22896
22897      <!-- Option 2: Load from config file -->
22898      <div class="option-card-wrap">
22899        <div class="option-card">
22900        <div class="option-icon load-config">
22901          <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>
22902        </div>
22903        <div class="card-body">
22904          <div class="card-left">
22905            <div class="card-text">
22906              <div class="option-title">Load a saved config</div>
22907              <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>
22908              <ul class="feature-list">
22909                <li>All 15 settings restored from the file</li>
22910                <li>Fully editable — change path or output dir</li>
22911                <li>Works with any scan-config.json</li>
22912              </ul>
22913            </div>
22914          </div>
22915          <div class="card-right">
22916            <div class="file-input-wrap">
22917              <button class="btn btn-secondary" id="load-config-btn" type="button">
22918                <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>
22919                Choose config file
22920              </button>
22921              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22922            </div>
22923            <p class="card-tip" id="config-file-name">Exported after every scan</p>
22924          </div>
22925        </div>
22926        </div>
22927      </div>
22928
22929      <!-- Option 3: Re-scan recent project -->
22930      <div class="option-card-wrap">
22931        <div class="option-card" id="recent-card">
22932        <div class="card-top-row">
22933          <div class="option-icon rescan">
22934            <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>
22935          </div>
22936          <div class="card-body">
22937            <div class="card-left">
22938              <div class="card-text">
22939                <div class="option-title">Re-scan a recent project</div>
22940                <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>
22941                <ul class="feature-list">
22942                  <li>All 15+ settings restored from the saved config</li>
22943                  <li>Path and output dir are editable before running</li>
22944                  <li>Only scans with a saved config appear here</li>
22945                </ul>
22946              </div>
22947            </div>
22948            <div class="card-right">
22949              <div class="rescan-count-box">
22950                <div class="rescan-count-num" id="rescan-count-num">—</div>
22951                <div class="rescan-count-label">saved configs</div>
22952              </div>
22953              <a class="btn btn-secondary" href="/view-reports">
22954                <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>
22955                View all runs
22956              </a>
22957              <p class="card-tip">Opens run history</p>
22958            </div>
22959          </div>
22960        </div>
22961        <div class="section-divider"></div>
22962        <div class="recent-list" id="recent-list">
22963          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22964        </div>
22965        </div>
22966      </div>
22967
22968    </div>
22969  </div>
22970
22971  <footer class="site-footer">
22972    local code analysis - metrics, history and reports
22973    &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>
22974    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22975    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22976    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22977    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22978  </footer>
22979
22980  <script nonce="{{ csp_nonce }}">
22981    (function () {
22982      var storageKey = 'oxide-sloc-theme';
22983      var body = document.body;
22984      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22985      var toggle = document.getElementById('theme-toggle');
22986      if (toggle) toggle.addEventListener('click', function () {
22987        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22988        body.classList.toggle('dark-theme', next === 'dark');
22989        try { localStorage.setItem(storageKey, next); } catch(e) {}
22990      });
22991
22992      (function randomizeWatermarks() {
22993        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22994        if (!wms.length) return;
22995        var placed = [];
22996        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; }
22997        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]; }
22998        var half = Math.floor(wms.length / 2);
22999        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; });
23000      })();
23001      (function spawnCodeParticles() {
23002        var container = document.getElementById('code-particles');
23003        if (!container) return;
23004        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'];
23005        var count = 38;
23006        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); }
23007      })();
23008      // Recent scans data injected from server
23009      var recentScans = {{ recent_scans_json|safe }};
23010
23011      function configToParams(cfg) {
23012        var p = new URLSearchParams();
23013        p.set('prefilled', '1');
23014        if (cfg.path) p.set('path', cfg.path);
23015        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23016        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23017        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23018        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23019        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23020        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23021        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23022        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23023        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23024        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23025        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23026        if (cfg.report_title) p.set('report_title', cfg.report_title);
23027        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23028        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23029        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23030        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23031        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23032        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23033        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23034        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23035        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23036        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23037        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23038        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23039        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23040        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23041        return p;
23042      }
23043
23044      // Build recent scan list (capped at 3 visible entries)
23045      var list = document.getElementById('recent-list');
23046      var noNote = document.getElementById('no-recent-note');
23047      var hasAny = false;
23048      var MAX_RECENT = 3;
23049      if (Array.isArray(recentScans)) {
23050        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23051        var shown = 0;
23052        validEntries.forEach(function (entry) {
23053          if (shown >= MAX_RECENT) return;
23054          shown++;
23055          hasAny = true;
23056          var item = document.createElement('div');
23057          item.className = 'recent-item';
23058          item.title = 'Restore all settings and open wizard';
23059          item.innerHTML =
23060            '<div class="recent-item-info">' +
23061              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23062              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
23063            '</div>' +
23064            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23065          item.addEventListener('click', function () {
23066            var params = configToParams(entry.config);
23067            window.location.href = '/scan?' + params.toString();
23068          });
23069          list.appendChild(item);
23070        });
23071        if (validEntries.length > MAX_RECENT) {
23072          var moreEl = document.createElement('div');
23073          moreEl.className = 'recent-more-link';
23074          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
23075          list.appendChild(moreEl);
23076        }
23077      }
23078      if (hasAny && noNote) noNote.style.display = 'none';
23079      // Update count badge
23080      var countEl = document.getElementById('rescan-count-num');
23081      if (countEl) {
23082        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23083        countEl.textContent = total > 0 ? total : '0';
23084      }
23085
23086      // Config file loader
23087      var fileInput = document.getElementById('config-file-input');
23088      var fileName = document.getElementById('config-file-name');
23089      var loadBtn = document.getElementById('load-config-btn');
23090      // Wire the visible button to open the hidden file picker.
23091      if (loadBtn && fileInput) {
23092        loadBtn.addEventListener('click', function () { fileInput.click(); });
23093      }
23094      if (fileInput) {
23095        fileInput.addEventListener('change', function () {
23096          var file = fileInput.files && fileInput.files[0];
23097          if (!file) return;
23098          if (fileName) fileName.textContent = '\u2713 ' + file.name;
23099          var reader = new FileReader();
23100          reader.onload = function (e) {
23101            try {
23102              var cfg = JSON.parse(e.target.result);
23103              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23104              var params = configToParams(cfg);
23105              window.location.href = '/scan?' + params.toString();
23106            } catch (err) {
23107              alert('Could not parse config file: ' + err.message);
23108            }
23109          };
23110          reader.readAsText(file);
23111        });
23112      }
23113
23114      function escHtml(s) {
23115        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
23116      }
23117    })();
23118  </script>
23119  <script nonce="{{ csp_nonce }}">
23120  (function(){
23121    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'}];
23122    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);});}
23123    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23124    function init(){
23125      var btn=document.getElementById('settings-btn');if(!btn)return;
23126      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23127      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>';
23128      document.body.appendChild(m);
23129      var g=document.getElementById('scheme-grid');
23130      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);});
23131      var cl=document.getElementById('settings-close');
23132      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);});})();
23133      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');});
23134      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23135      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23136    }
23137    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23138  }());
23139  </script>
23140  <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]';
23141  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;}
23142  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>
23143</body>
23144</html>
23145"##,
23146    ext = "html"
23147)]
23148struct ScanSetupTemplate {
23149    version: &'static str,
23150    recent_scans_json: String,
23151    csp_nonce: String,
23152}
23153
23154#[derive(Template)]
23155#[template(
23156    source = r##"
23157<!doctype html>
23158<html lang="en">
23159<head>
23160  <meta charset="utf-8">
23161  <meta name="viewport" content="width=device-width, initial-scale=1">
23162  <title>OxideSLOC | {{ report_title }} | Report</title>
23163  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23164  <style nonce="{{ csp_nonce }}">
23165    :root {
23166      --radius: 18px;
23167      --bg: #f5efe8;
23168      --surface: rgba(255,255,255,0.82);
23169      --surface-2: #fbf7f2;
23170      --surface-3: #efe6dc;
23171      --line: #e6d0bf;
23172      --line-strong: #dcb89f;
23173      --text: #43342d;
23174      --muted: #7b675b;
23175      --muted-2: #a08777;
23176      --nav: #b85d33;
23177      --nav-2: #7a371b;
23178      --accent: #6f9bff;
23179      --accent-2: #4a78ee;
23180      --oxide: #d37a4c;
23181      --oxide-2: #b35428;
23182      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23183      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23184      --success-bg: #e8f5ed;
23185      --success-text: #1a8f47;
23186      --info-bg: #eef3ff;
23187      --info-text: #4467d8;
23188    }
23189
23190    body.dark-theme {
23191      --bg: #1b1511;
23192      --surface: #261c17;
23193      --surface-2: #2d221d;
23194      --surface-3: #372922;
23195      --line: #524238;
23196      --line-strong: #6c5649;
23197      --text: #f5ece6;
23198      --muted: #c7b7aa;
23199      --muted-2: #aa9485;
23200      --nav: #b85d33;
23201      --nav-2: #7a371b;
23202      --accent: #6f9bff;
23203      --accent-2: #4a78ee;
23204      --oxide: #d37a4c;
23205      --oxide-2: #b35428;
23206      --shadow: 0 18px 42px rgba(0,0,0,0.28);
23207      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23208      --success-bg: #163927;
23209      --success-text: #8fe2a8;
23210      --info-bg: #1c2847;
23211      --info-text: #a9c1ff;
23212    }
23213
23214    * { box-sizing: border-box; }
23215    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); }
23216    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23217    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23218    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23219    .top-nav, .page { position: relative; z-index: 2; }
23220    .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); }
23221    .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; }
23222    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23223    .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)); }
23224    .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; }
23225    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23226    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23227    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23228    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23229    .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; }
23230    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23231    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23232    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23233    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23234    @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; } }
23235    .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; }
23236    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23237    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23238    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23239    .theme-toggle .icon-sun { display:none; }
23240    body.dark-theme .theme-toggle .icon-sun { display:block; }
23241    body.dark-theme .theme-toggle .icon-moon { display:none; }
23242    .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;}
23243    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23244    .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);}
23245    .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;}
23246    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23247    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23248    .settings-modal-body{padding:14px 16px 16px;}
23249    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23250    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23251    .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;}
23252    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23253    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23254    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23255    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23256    .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;}
23257    .tz-select:focus{border-color:var(--oxide);}
23258    .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; }
23259    .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;}
23260    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23261    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23262    .hero, .panel { padding: 22px; }
23263    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23264    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23265    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23266    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23267    .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; }
23268    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23269    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23270    .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; }
23271    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23272    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23273    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23274    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23275    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23276    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23277    .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); }
23278    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23279    .delta-card-val { font-size:16px; font-weight:800; }
23280    .delta-card-val.pos { color:#1e7e34; }
23281    .delta-card-val.neg { color:var(--neg); }
23282    .delta-card-val.mod { color:#b35428; }
23283    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23284    .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; }
23285    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23286    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23287    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23288    .compare-ts { font-size:13px; color:var(--muted); }
23289    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23290    .compare-arrow { color: var(--muted); }
23291    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23292    .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; }
23293    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23294    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23295    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23296    .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; }
23297    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23298    .run-mgmt-card .action-buttons { justify-content:center; }
23299    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23300    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23301    .button, .copy-button {
23302      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;
23303    }
23304    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23305    @keyframes spin { to { transform: rotate(360deg); } }
23306    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23307    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23308    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23309    .path-item strong { display: block; margin-bottom: 6px; }
23310    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23311    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23312    .path-subitem { flex: 1; }
23313    .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); }
23314    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); }
23315    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23316    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23317    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23318    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23319    th { color: var(--muted); font-weight: 700; }
23320    tr:last-child td { border-bottom: none; }
23321    #subm-tbl col:nth-child(1){width:15%;}
23322    #subm-tbl col:nth-child(2){width:31%;}
23323    #subm-tbl col:nth-child(3){width:9%;}
23324    #subm-tbl col:nth-child(4){width:9%;}
23325    #subm-tbl col:nth-child(5){width:9%;}
23326    #subm-tbl col:nth-child(6){width:9%;}
23327    #subm-tbl col:nth-child(7){width:9%;}
23328    #subm-tbl col:nth-child(8){width:9%;}
23329    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23330    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23331    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23332    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23333    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23334    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23335    .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; }
23336    .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; }
23337    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23338    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23339    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23340    .muted { color: var(--muted); }
23341    /* Run-ID chip row (mirrors HTML report) */
23342    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23343    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23344    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23345    .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; }
23346    .run-id-chip[data-copy] { cursor:pointer; }
23347    a.run-id-chip { text-decoration:none; cursor:pointer; }
23348    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23349    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23350    .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; }
23351    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23352    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23353    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23354    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23355    a.commit-link-value { color:inherit; text-decoration:none; }
23356    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23357    .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; }
23358    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23359    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23360    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23361    .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; }
23362    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23363    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23364    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23365    /* Meta chips row */
23366    .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%; }
23367    .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; }
23368    .meta-chip:last-child { border-right:none; }
23369    .meta-chip b { color:var(--text); font-weight:700; }
23370    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23371    .site-footer a{color:var(--muted);}
23372    .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; }
23373    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23374    .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; }
23375    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23376    /* Stat chips (matches HTML report) */
23377    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23378    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23379    /* Hero stat strip: uniform grid where every card is the same width and the
23380       columns line up across both rows. JS sets the column count to ceil(n/2) so
23381       the cards always occupy exactly two rows; when the count is odd the last
23382       card spans two columns to fill the trailing cell with no empty gap. */
23383    .summary-strip-hero { align-items:stretch; }
23384    .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; }
23385    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23386    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23387    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23388    .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; }
23389    .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); }
23390    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23391    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23392    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23393    .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; }
23394    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23395    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23396    .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); }
23397    .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); }
23398    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23399    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23400    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23401    /* Submodule panel */
23402    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23403    /* Metrics tables stack */
23404    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23405    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23406    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23407    .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)); }
23408    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23409    /* Metrics table */
23410    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23411    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23412    .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; }
23413    .metrics-table thead th:not(:first-child) { text-align: right; }
23414    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23415    .metrics-table tbody tr:last-child td { border-bottom: none; }
23416    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23417    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23418    .metrics-table tbody tr:hover td { background: var(--surface-2); }
23419    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23420    .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; }
23421    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23422    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23423    .mt-val-pos { color: var(--pos); font-weight: 700; }
23424    .mt-val-neg { color: var(--neg); font-weight: 700; }
23425    .mt-val-zero { color: var(--muted); }
23426    .mt-val-mod { color: var(--oxide-2); }
23427    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23428    @media (max-width: 1180px) {
23429      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23430      .nav-project-slot, .nav-status { justify-content:flex-start; }
23431      .hero-top { flex-direction: column; }
23432      .run-mgmt-strip { flex-direction: column; }
23433    }
23434    .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;}
23435    @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));}}
23436    .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;}
23437    /* ── Result-page chart controls ─────────────────────────────────────────── */
23438    .r-chart-section{margin-bottom:24px;}
23439    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23440    .section-pair > .panel{flex-shrink:0;}
23441    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23442    .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;}
23443    .r-chart-select:focus{border-color:var(--accent);}
23444    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23445    .r-chart-container svg{display:block;width:100%;height:auto;}
23446    .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;}
23447    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23448    .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;}
23449    .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);}
23450    .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;}
23451    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23452    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23453    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23454    .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;}
23455    .r-chart-modal-close:hover{opacity:.7;}
23456    body.dark-theme .r-chart-modal{background:var(--surface);}
23457    .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;}
23458    .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);}
23459    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23460    .lang-bar-row:hover{transform:translateY(-2px);}
23461    .lang-bar-row .rchit:hover{filter:none;transform:none;}
23462    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23463    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23464    .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;}
23465    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23466    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23467    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23468    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23469    #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;}
23470    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23471    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23472    .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;}
23473    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23474    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23475    .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;}
23476    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23477    .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;}
23478    .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;}
23479    body.has-report-banner .top-nav{top:27px;}
23480    body.has-report-banner{padding-bottom:27px;}
23481  </style>
23482</head>
23483<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23484  <div class="background-watermarks" aria-hidden="true">
23485    <img src="/images/logo/logo-text.png" alt="" />
23486    <img src="/images/logo/logo-text.png" alt="" />
23487    <img src="/images/logo/logo-text.png" alt="" />
23488    <img src="/images/logo/logo-text.png" alt="" />
23489    <img src="/images/logo/logo-text.png" alt="" />
23490    <img src="/images/logo/logo-text.png" alt="" />
23491    <img src="/images/logo/logo-text.png" alt="" />
23492    <img src="/images/logo/logo-text.png" alt="" />
23493    <img src="/images/logo/logo-text.png" alt="" />
23494    <img src="/images/logo/logo-text.png" alt="" />
23495    <img src="/images/logo/logo-text.png" alt="" />
23496    <img src="/images/logo/logo-text.png" alt="" />
23497    <img src="/images/logo/logo-text.png" alt="" />
23498    <img src="/images/logo/logo-text.png" alt="" />
23499  </div>
23500  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23501  {% if let Some(banner) = report_header_footer %}
23502  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23503  {% endif %}
23504  <div class="top-nav">
23505    <div class="top-nav-inner">
23506      <a class="brand" href="/">
23507        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23508        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23509      </a>
23510      <div class="nav-project-slot">
23511        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23512      </div>
23513      <div class="nav-status">
23514        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23515        <div class="nav-dropdown">
23516          <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>
23517          <div class="nav-dropdown-menu">
23518            <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>
23519          </div>
23520        </div>
23521        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23522        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23523        <div class="nav-dropdown">
23524          <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>
23525          <div class="nav-dropdown-menu">
23526            <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>
23527          </div>
23528        </div>
23529        <div class="server-status-wrap" id="server-status-wrap">
23530          <div class="nav-pill server-online-pill" id="server-status-pill">
23531            <span class="status-dot" id="status-dot"></span>
23532            <span id="server-status-label">Server</span>
23533            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23534          </div>
23535          <div class="server-status-tip">
23536            OxideSLOC is running — accessible on your network.
23537            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23538          </div>
23539        </div>
23540        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23541          <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>
23542        </button>
23543        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23544          <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>
23545          <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>
23546        </button>
23547      </div>
23548    </div>
23549  </div>
23550
23551  <div class="page">
23552    <section class="hero">
23553      <div class="hero-top">
23554        <div>
23555          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23556            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23557            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23558            <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>
23559          </div>
23560        </div>
23561        <div class="hero-quick-actions">
23562          {% if server_mode %}
23563          <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>
23564          {% else %}
23565          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23566          {% endif %}
23567          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23568          {% if !server_mode %}
23569          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23570          {% endif %}
23571          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23572          <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>
23573        </div>
23574      </div>
23575
23576      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23577      <div class="run-id-row">
23578        <span class="run-id-chip" data-copy="{{ run_id }}">
23579          <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>
23580          <span class="run-id-chip-value">{{ run_id }}</span>
23581          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23582        </span>
23583        {% match git_commit_long %}
23584          {% when Some with (long_sha) %}
23585          {% match git_commit_url %}
23586            {% when Some with (commit_url) %}
23587            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23588              <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>
23589              <span class="run-id-chip-value">{{ long_sha }}</span>
23590              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23591            </a>
23592            {% when None %}
23593            <span class="run-id-chip" data-copy="{{ long_sha }}">
23594              <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>
23595              <span class="run-id-chip-value">{{ long_sha }}</span>
23596              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23597            </span>
23598          {% endmatch %}
23599          {% when None %}
23600          <span class="run-id-chip muted-chip">
23601            <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>
23602            <span class="run-id-chip-value">Not detected</span>
23603            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23604          </span>
23605        {% endmatch %}
23606        {% match git_branch %}
23607          {% when Some with (branch) %}
23608          {% match git_branch_url %}
23609            {% when Some with (branch_url) %}
23610            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23611              <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>
23612              <span class="run-id-chip-value">{{ branch }}</span>
23613              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23614            </a>
23615            {% when None %}
23616            <span class="run-id-chip">
23617              <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>
23618              <span class="run-id-chip-value">{{ branch }}</span>
23619              <span class="chip-tooltip">Git branch active at scan time</span>
23620            </span>
23621          {% endmatch %}
23622          {% when None %}
23623          <span class="run-id-chip muted-chip">
23624            <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>
23625            <span class="run-id-chip-value">Not detected</span>
23626            <span class="chip-tooltip">No Git branch was found for this scan</span>
23627          </span>
23628        {% endmatch %}
23629        {% match git_author %}
23630          {% when Some with (author) %}
23631          <span class="run-id-chip" data-author="{{ author }}">
23632            <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>
23633            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23634            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23635          </span>
23636          {% when None %}
23637          <span class="run-id-chip muted-chip">
23638            <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>
23639            <span class="run-id-chip-value">Not detected</span>
23640            <span class="chip-tooltip">No commit author was found for this scan</span>
23641          </span>
23642        {% endmatch %}
23643      </div>
23644
23645      <!-- Scan metadata row -->
23646      <div class="meta">
23647        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23648        <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23649        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23650        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23651        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23652      </div>
23653
23654      <!-- All summary stat chips in one unified strip (8 columns) -->
23655      <div class="summary-strip summary-strip-hero">
23656        <div class="stat-chip" data-raw="{{ physical_lines }}">
23657          <div class="stat-chip-label">Physical lines</div>
23658          <div class="stat-chip-val">{{ physical_lines }}</div>
23659          <div class="stat-chip-exact"></div>
23660          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23661        </div>
23662        <div class="stat-chip" data-raw="{{ code_lines }}">
23663          <div class="stat-chip-label">Code</div>
23664          <div class="stat-chip-val">{{ code_lines }}</div>
23665          <div class="stat-chip-exact"></div>
23666          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23667        </div>
23668        <div class="stat-chip" data-raw="{{ comment_lines }}">
23669          <div class="stat-chip-label">Comments</div>
23670          <div class="stat-chip-val">{{ comment_lines }}</div>
23671          <div class="stat-chip-exact"></div>
23672          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23673        </div>
23674        <div class="stat-chip" data-raw="{{ blank_lines }}">
23675          <div class="stat-chip-label">Blank</div>
23676          <div class="stat-chip-val">{{ blank_lines }}</div>
23677          <div class="stat-chip-exact"></div>
23678          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23679        </div>
23680        <div class="stat-chip" data-raw="{{ mixed_lines }}">
23681          <div class="stat-chip-label">Mixed separate</div>
23682          <div class="stat-chip-val">{{ mixed_lines }}</div>
23683          <div class="stat-chip-exact"></div>
23684          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23685        </div>
23686        <div class="stat-chip" data-raw="{{ functions }}">
23687          <div class="stat-chip-label">Functions</div>
23688          <div class="stat-chip-val">{{ functions }}</div>
23689          <div class="stat-chip-exact"></div>
23690          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23691        </div>
23692        <div class="stat-chip" data-raw="{{ classes }}">
23693          <div class="stat-chip-label">Classes / Types</div>
23694          <div class="stat-chip-val">{{ classes }}</div>
23695          <div class="stat-chip-exact"></div>
23696          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23697        </div>
23698        <div class="stat-chip" data-raw="{{ variables }}">
23699          <div class="stat-chip-label">Variables</div>
23700          <div class="stat-chip-val">{{ variables }}</div>
23701          <div class="stat-chip-exact"></div>
23702          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23703        </div>
23704        <div class="stat-chip" data-raw="{{ imports }}">
23705          <div class="stat-chip-label">Imports</div>
23706          <div class="stat-chip-val">{{ imports }}</div>
23707          <div class="stat-chip-exact"></div>
23708          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23709        </div>
23710        <div class="stat-chip" data-raw="{{ test_count }}">
23711          <div class="stat-chip-label">Tests</div>
23712          <div class="stat-chip-val">{{ test_count }}</div>
23713          <div class="stat-chip-exact"></div>
23714          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23715        </div>
23716        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23717          <div class="stat-chip-label">Code density</div>
23718          <div class="stat-chip-val stat-chip-density-val">—</div>
23719          <div class="stat-chip-exact"></div>
23720          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23721        </div>
23722        <div class="stat-chip" data-raw="{{ files_analyzed }}">
23723          <div class="stat-chip-label">Files analyzed</div>
23724          <div class="stat-chip-val">{{ files_analyzed }}</div>
23725          <div class="stat-chip-exact"></div>
23726          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23727        </div>
23728        {% if cyclomatic_complexity > 0 %}
23729        <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 %}>
23730          <div class="stat-chip-label">Complexity score</div>
23731          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23732          <div class="stat-chip-exact"></div>
23733          <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>
23734        </div>
23735        {% endif %}
23736        {% if let Some(ls) = lsloc %}
23737        <div class="stat-chip" data-raw="{{ ls }}">
23738          <div class="stat-chip-label">Logical SLOC</div>
23739          <div class="stat-chip-val">{{ ls }}</div>
23740          <div class="stat-chip-exact"></div>
23741          <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>
23742        </div>
23743        {% endif %}
23744        {% if uloc > 0 %}
23745        <div class="stat-chip" data-raw="{{ uloc }}">
23746          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23747          <div class="stat-chip-val">{{ uloc }}</div>
23748          <div class="stat-chip-exact"></div>
23749          <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>
23750        </div>
23751        {% endif %}
23752        {% if uloc > 0 && dryness_pct_str != "" %}
23753        <div class="stat-chip">
23754          <div class="stat-chip-label">DRYness</div>
23755          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23756          <div class="stat-chip-exact"></div>
23757          <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>
23758        </div>
23759        {% endif %}
23760        {% if duplicate_group_count > 0 %}
23761        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23762          <div class="stat-chip-label">Duplicate groups</div>
23763          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23764          <div class="stat-chip-exact"></div>
23765          <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>
23766        </div>
23767        {% endif %}
23768        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23769             odd, so the strip always forms exactly two full rows with every column
23770             aligned and every card the same width (no oversized card, no gap). -->
23771        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23772          <div class="stat-chip-label">Assertions</div>
23773          <div class="stat-chip-val">{{ test_assertion_count }}</div>
23774          <div class="stat-chip-exact"></div>
23775          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23776        </div>
23777      </div>
23778
23779      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23780      <div class="compare-banner">
23781        <div class="compare-banner-body">
23782          <div class="compare-banner-top">
23783          <div class="compare-banner-meta">
23784            <span class="compare-label">Previous scan</span>
23785            <span class="compare-ts">{{ prev_ts }}</span>
23786            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23787            {% if let Some(prev_code) = prev_run_code_lines %}
23788            <div class="compare-banner-stats" style="margin-top:4px;">
23789              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23790              <span class="compare-arrow">→</span>
23791              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23792              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23793              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23794            </div>
23795            {% endif %}
23796          </div>
23797          {% if delta_lines_added.is_some() %}
23798          <div class="delta-cards-inline">
23799            <div class="delta-card-inline">
23800              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23801              <div class="delta-card-lbl">lines added</div>
23802              <div class="delta-card-tip">Code lines added since the previous scan</div>
23803            </div>
23804            <div class="delta-card-inline">
23805              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
23806              <div class="delta-card-lbl">lines removed</div>
23807              <div class="delta-card-tip">Code lines removed since the previous scan</div>
23808            </div>
23809            <div class="delta-card-inline">
23810              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23811              <div class="delta-card-lbl">unmodified lines</div>
23812              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23813            </div>
23814            <div class="delta-card-inline">
23815              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23816              <div class="delta-card-lbl">files modified</div>
23817              <div class="delta-card-tip">Files with at least one line changed</div>
23818            </div>
23819            <div class="delta-card-inline">
23820              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23821              <div class="delta-card-lbl">files added</div>
23822              <div class="delta-card-tip">New files added since the previous scan</div>
23823            </div>
23824            <div class="delta-card-inline">
23825              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23826              <div class="delta-card-lbl">files removed</div>
23827              <div class="delta-card-tip">Files deleted since the previous scan</div>
23828            </div>
23829            <div class="delta-card-inline">
23830              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23831              <div class="delta-card-lbl">files unchanged</div>
23832              <div class="delta-card-tip">Files with no changes since the previous scan</div>
23833            </div>
23834            <div class="delta-card-inline">
23835              <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
23836              <div class="delta-card-lbl">files total</div>
23837              <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
23838            </div>
23839          </div>
23840          {% else %}
23841          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23842            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23843          </p>
23844          {% endif %}
23845          </div>
23846          <div class="compare-banner-actions">
23847            <div class="compare-banner-actions-left">
23848              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23849              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23850            </div>
23851            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23852          </div>
23853        </div>
23854      </div>
23855      {% endif %}{% endif %}
23856
23857      <div class="action-grid">
23858        <div class="action-card">
23859          <h3>HTML report</h3>
23860          <div class="action-buttons">
23861            {% match html_url %}
23862              {% when Some with (url) %}
23863                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23864              {% when None %}{% endmatch %}
23865            {% match html_download_url %}
23866              {% when Some with (url) %}
23867                <a class="button secondary" href="{{ url }}">Download HTML</a>
23868              {% when None %}{% endmatch %}
23869            {% match html_path %}
23870              {% when Some with (_path) %}{% when None %}{% endmatch %}
23871            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23872          </div>
23873        </div>
23874        <div class="action-card">
23875          <h3>PDF report</h3>
23876          <div class="action-buttons">
23877            {% match pdf_url %}
23878              {% when Some with (url) %}
23879                {% if pdf_generating %}
23880                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23881                    <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>
23882                    Generating PDF…
23883                  </button>
23884                {% else %}
23885                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23886                {% endif %}
23887              {% when None %}
23888                {% match html_url %}
23889                  {% when Some with (_hurl) %}
23890                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23891                    <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>
23892                  {% when None %}
23893                    <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;">
23894                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23895                    </p>
23896                {% endmatch %}
23897            {% endmatch %}
23898            {% match pdf_download_url %}
23899              {% when Some with (url) %}
23900                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23901              {% when None %}{% endmatch %}
23902            {% match pdf_url %}
23903              {% when Some with (_) %}
23904                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23905              {% when None %}{% endmatch %}
23906          </div>
23907        </div>
23908        <div class="action-card">
23909          <h3>JSON result</h3>
23910          <div class="action-buttons">
23911            {% match json_url %}
23912              {% when Some with (url) %}
23913                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23914              {% when None %}{% endmatch %}
23915            {% match json_download_url %}
23916              {% when Some with (url) %}
23917                <a class="button secondary" href="{{ url }}">Download JSON</a>
23918              {% when None %}{% endmatch %}
23919            {% match json_path %}
23920              {% when Some with (_path) %}
23921                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23922              {% when None %}
23923                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23924              {% endmatch %}
23925          </div>
23926        </div>
23927        <div class="action-card">
23928          <h3>Scan config</h3>
23929          <div class="action-buttons">
23930            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23931            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23932            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23933          </div>
23934        </div>
23935        {% if confluence_configured %}
23936        <div class="action-card" id="confluenceCard">
23937          <h3>Confluence</h3>
23938          <div class="action-buttons">
23939            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23940            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23941          </div>
23942          <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>
23943        </div>
23944        {% endif %}
23945      </div>
23946      {% if confluence_configured %}
23947      <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;">
23948        <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);">
23949          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23950          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23951          <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;">
23952          <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>
23953          <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;">
23954          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23955          <div style="display:flex;gap:10px;justify-content:flex-end;">
23956            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23957            <button class="button" id="confSubmitBtn" type="button">Post</button>
23958          </div>
23959        </div>
23960      </div>
23961      {% endif %}
23962      <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;">
23963        <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);">
23964          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
23965          <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>
23966          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23967          <div style="display:flex;gap:18px;justify-content:flex-end;">
23968            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23969            <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>
23970          </div>
23971        </div>
23972      </div>
23973      {% if !submodule_rows.is_empty() %}
23974      <div class="submodule-panel">
23975        <div class="toolbar-row">
23976          <div>
23977            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23978            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23979          </div>
23980          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23981        </div>
23982        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23983        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23984          <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>
23985          <thead>
23986            <tr>
23987              <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>
23988              <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>
23989              <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>
23990              <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>
23991              <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>
23992              <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>
23993              <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>
23994              <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>
23995            </tr>
23996          </thead>
23997          <tbody>
23998            {% for row in submodule_rows %}
23999            <tr>
24000              <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>
24001              <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>
24002              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24003              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24004              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24005              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24006              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24007              <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>
24008            </tr>
24009            {% endfor %}
24010          </tbody>
24011        </table>
24012        </div>
24013      </div>
24014      {% endif %}
24015
24016      <div class="metrics-tables-stack">
24017
24018        <div class="metrics-table-wrap">
24019          <div class="metrics-table-title">Files</div>
24020          <table class="metrics-table">
24021            <thead>
24022              <tr>
24023                <th>Metric</th>
24024                <th>This Run</th>
24025                <th>Previous</th>
24026                <th>Change</th>
24027              </tr>
24028            </thead>
24029            <tbody>
24030              <tr>
24031                <td>Files analyzed</td>
24032                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24033                <td>{{ prev_fa_str|commas }}</td>
24034                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24035              </tr>
24036              <tr>
24037                <td>Files skipped</td>
24038                <td>{{ files_skipped|commas }}</td>
24039                <td>{{ prev_fs_str|commas }}</td>
24040                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24041              </tr>
24042              <tr>
24043                <td>Files modified</td>
24044                <td class="mt-val-na">—</td>
24045                <td class="mt-val-na">—</td>
24046                <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>
24047              </tr>
24048              <tr>
24049                <td>Files unchanged</td>
24050                <td class="mt-val-na">—</td>
24051                <td class="mt-val-na">—</td>
24052                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24053              </tr>
24054              <tr>
24055                <td>Files total</td>
24056                <td class="mt-val-na">—</td>
24057                <td class="mt-val-na">—</td>
24058                <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24059              </tr>
24060            </tbody>
24061          </table>
24062        </div>
24063
24064        <div class="metrics-table-wrap">
24065          <div class="metrics-table-title">Line Counts</div>
24066          <table class="metrics-table">
24067            <thead>
24068              <tr>
24069                <th>Metric</th>
24070                <th>This Run</th>
24071                <th>Previous</th>
24072                <th>Change</th>
24073              </tr>
24074            </thead>
24075            <tbody>
24076              <tr>
24077                <td>Physical lines</td>
24078                <td class="mt-val-large">{{ physical_lines|commas }}</td>
24079                <td>{{ prev_pl_str|commas }}</td>
24080                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24081              </tr>
24082              <tr>
24083                <td>Code lines</td>
24084                <td class="mt-val-large">{{ code_lines|commas }}</td>
24085                <td>{{ prev_cl_str|commas }}</td>
24086                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24087              </tr>
24088              <tr>
24089                <td>Comment lines</td>
24090                <td>{{ comment_lines|commas }}</td>
24091                <td>{{ prev_cml_str|commas }}</td>
24092                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24093              </tr>
24094              <tr>
24095                <td>Blank lines</td>
24096                <td>{{ blank_lines|commas }}</td>
24097                <td>{{ prev_bl_str|commas }}</td>
24098                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24099              </tr>
24100              <tr>
24101                <td>Mixed (separate)</td>
24102                <td>{{ mixed_lines|commas }}</td>
24103                <td class="mt-val-na">—</td>
24104                <td class="mt-val-na">—</td>
24105              </tr>
24106            </tbody>
24107          </table>
24108        </div>
24109
24110        <div class="metrics-tables-lower">
24111          <div class="metrics-table-wrap">
24112            <div class="metrics-table-title">Code Structure</div>
24113            <table class="metrics-table">
24114              <thead>
24115                <tr>
24116                  <th>Metric</th>
24117                  <th>This Run</th>
24118                </tr>
24119              </thead>
24120              <tbody>
24121                <tr>
24122                  <td>Functions</td>
24123                  <td>{{ functions|commas }}</td>
24124                </tr>
24125                <tr>
24126                  <td>Classes / Types</td>
24127                  <td>{{ classes|commas }}</td>
24128                </tr>
24129                <tr>
24130                  <td>Variables</td>
24131                  <td>{{ variables|commas }}</td>
24132                </tr>
24133                <tr>
24134                  <td>Imports</td>
24135                  <td>{{ imports|commas }}</td>
24136                </tr>
24137              </tbody>
24138            </table>
24139          </div>
24140
24141          <div class="metrics-table-wrap">
24142            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24143            <table class="metrics-table">
24144              <thead>
24145                <tr>
24146                  <th>Metric</th>
24147                  <th>Change</th>
24148                </tr>
24149              </thead>
24150              <tbody>
24151                <tr>
24152                  <td>Lines added</td>
24153                  <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>
24154                </tr>
24155                <tr>
24156                  <td>Lines removed</td>
24157                  <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>
24158                </tr>
24159                <tr>
24160                  <td>Lines modified (net)</td>
24161                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24162                </tr>
24163                <tr>
24164                  <td>Lines unmodified</td>
24165                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24166                </tr>
24167              </tbody>
24168            </table>
24169          </div>
24170        </div>
24171
24172      </div>
24173
24174      <div class="path-list">
24175        <div class="path-item">
24176          <div class="path-item-label">Project path</div>
24177          {% 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 %}
24178        </div>
24179        <div class="path-item">
24180          <div class="path-item-label">Git branch</div>
24181          {% if let Some(branch) = git_branch %}
24182          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24183          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24184          {% else %}
24185          <code style="color:var(--muted)">—</code>
24186          {% endif %}
24187        </div>
24188        <div class="path-item">
24189          <div class="path-item-label">Output folder</div>
24190          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24191        </div>
24192        <div class="path-item">
24193          <div class="path-item-label">Run ID</div>
24194          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24195            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24196            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24197          </div>
24198        </div>
24199      </div>
24200    </section>
24201
24202    {% if has_cocomo %}
24203    <div class="cocomo-box" style="margin-top:24px;">
24204      <div class="cocomo-box-head">
24205        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
24206        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24207          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24208          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24209        </span>
24210      </div>
24211      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24212        <div class="stat-chip">
24213          <div class="stat-chip-label">Person-months</div>
24214          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24215          <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>
24216        </div>
24217        <div class="stat-chip">
24218          <div class="stat-chip-label">Schedule (months)</div>
24219          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24220          <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>
24221        </div>
24222        <div class="stat-chip">
24223          <div class="stat-chip-label">Avg. Team Size</div>
24224          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24225          <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>
24226        </div>
24227        <div class="stat-chip">
24228          <div class="stat-chip-label">Input KSLOC</div>
24229          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24230          <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>
24231        </div>
24232      </div>
24233      <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>
24234    </div>
24235    {% endif %}
24236
24237    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24238    <div class="cocomo-box" style="margin-top:24px;">
24239      <div class="cocomo-box-head">
24240        <span class="cocomo-box-title">Tests &amp; Coverage</span>
24241        {% if has_coverage_data %}
24242        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24243          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24244        </span>
24245        {% endif %}
24246      </div>
24247      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24248        <div class="stat-chip">
24249          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24250          <div class="stat-chip-label">Test Functions</div>
24251          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24252        </div>
24253        <div class="stat-chip">
24254          {% if has_coverage_data %}
24255          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24256          {% else %}
24257          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24258          {% endif %}
24259          <div class="stat-chip-label">Line Coverage</div>
24260          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24261        </div>
24262        <div class="stat-chip">
24263          {% if !cov_fn_pct.is_empty() %}
24264          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24265          {% else %}
24266          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24267          {% endif %}
24268          <div class="stat-chip-label">Fn Coverage</div>
24269          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24270        </div>
24271        <div class="stat-chip">
24272          {% if !cov_branch_pct.is_empty() %}
24273          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24274          {% else %}
24275          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24276          {% endif %}
24277          <div class="stat-chip-label">Branch Coverage</div>
24278          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24279        </div>
24280      </div>
24281      {% if has_coverage_data %}
24282      <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>
24283      {% else %}
24284      <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>
24285      {% endif %}
24286    </div>
24287
24288    <div class="section-pair">
24289    <section class="panel">
24290        <div class="toolbar-row">
24291          <div>
24292            <h2>Language Breakdown</h2>
24293            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24294          </div>
24295          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24296        </div>
24297        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24298    </section>
24299
24300    <section class="panel r-chart-section">
24301      <div class="toolbar-row" style="margin-bottom:16px;">
24302        <div>
24303          <h2>Visualizations</h2>
24304          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24305        </div>
24306      </div>
24307
24308      <div class="r-viz-grid">
24309        <div class="r-viz-card">
24310          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24311            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24312            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24313          </div>
24314          <div class="r-chart-tab-bar">
24315            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24316            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24317          </div>
24318          <div class="r-chart-container" id="r-composition-chart"></div>
24319        </div>
24320        <div class="r-viz-card">
24321          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24322            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24323            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24324          </div>
24325          <div class="r-chart-container" id="r-scatter-chart"></div>
24326        </div>
24327        {% if has_semantic_data %}
24328        <div class="r-viz-card">
24329          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24330            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24331            <select class="r-chart-select" id="r-semantic-metric">
24332              <option value="functions">Functions</option>
24333              <option value="classes">Classes</option>
24334              <option value="variables">Variables</option>
24335              <option value="imports">Imports</option>
24336              <option value="tests">Tests</option>
24337            </select>
24338            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24339          </div>
24340          <div class="r-chart-container" id="r-semantic-chart"></div>
24341        </div>
24342        {% endif %}
24343        <div class="r-viz-card">
24344          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24345            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24346            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24347          </div>
24348          <div class="r-chart-container" id="r-density-chart"></div>
24349        </div>
24350        <div class="r-viz-card">
24351          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24352            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24353            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24354          </div>
24355          <div class="r-chart-container" id="r-avglines-chart"></div>
24356        </div>
24357        <div class="r-viz-card">
24358          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24359            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24360            <select class="r-chart-select" id="r-sub-metric">
24361              <option value="code">Code Lines</option>
24362              <option value="comment">Comments</option>
24363              <option value="blank">Blank Lines</option>
24364              <option value="physical">Physical Lines</option>
24365              <option value="files">Files</option>
24366            </select>
24367            <select class="r-chart-select" id="r-sub-sort">
24368              <option value="desc">Value ↓</option>
24369              <option value="asc">Value ↑</option>
24370              <option value="name">Name A→Z</option>
24371            </select>
24372            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24373          </div>
24374          <div class="r-chart-container" id="r-submodule-chart"></div>
24375        </div>
24376      </div>
24377
24378    </section>
24379    </div>
24380
24381  </div>
24382
24383  <div id="r-tt" aria-hidden="true"></div>
24384
24385  <script nonce="{{ csp_nonce }}">
24386    (function () {
24387      var body = document.body;
24388      var themeToggle = document.getElementById('theme-toggle');
24389      var storageKey = 'oxide-sloc-theme';
24390
24391      function applyTheme(theme) {
24392        body.classList.toggle('dark-theme', theme === 'dark');
24393      }
24394
24395      function loadSavedTheme() {
24396        try {
24397          var saved = localStorage.getItem(storageKey);
24398          if (saved === 'dark' || saved === 'light') {
24399            applyTheme(saved);
24400          }
24401        } catch (e) {}
24402      }
24403
24404      if (themeToggle) {
24405        themeToggle.addEventListener('click', function () {
24406          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24407          applyTheme(nextTheme);
24408          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24409        });
24410      }
24411
24412      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24413        button.addEventListener('click', function () {
24414          var value = button.getAttribute('data-copy-value') || '';
24415          if (!value) return;
24416          var originalText = button.textContent;
24417          function flashSuccess() {
24418            button.textContent = 'Copied!';
24419            setTimeout(function () { button.textContent = originalText; }, 1800);
24420          }
24421          function flashFail() {
24422            button.textContent = 'Copy failed';
24423            setTimeout(function () { button.textContent = originalText; }, 2000);
24424          }
24425          if (navigator.clipboard && navigator.clipboard.writeText) {
24426            navigator.clipboard.writeText(value).then(flashSuccess, function () {
24427              fallbackCopy(value, flashSuccess, flashFail);
24428            });
24429          } else {
24430            fallbackCopy(value, flashSuccess, flashFail);
24431          }
24432        });
24433      });
24434      function fallbackCopy(text, onSuccess, onFail) {
24435        try {
24436          var ta = document.createElement('textarea');
24437          ta.value = text;
24438          ta.style.position = 'fixed';
24439          ta.style.top = '-9999px';
24440          ta.style.left = '-9999px';
24441          document.body.appendChild(ta);
24442          ta.focus();
24443          ta.select();
24444          var ok = document.execCommand('copy');
24445          document.body.removeChild(ta);
24446          if (ok) { onSuccess(); } else { onFail(); }
24447        } catch (e) { onFail(); }
24448      }
24449
24450      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24451        btn.addEventListener('click', function () {
24452          var folder = btn.getAttribute('data-folder') || '';
24453          if (!folder) return;
24454          var orig = btn.textContent;
24455          fetch('/open-path?path=' + encodeURIComponent(folder))
24456            .then(function (r) { return r.json(); })
24457            .then(function (d) {
24458              if (d && d.server_mode_disabled) {
24459                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24460              } else if (d && d.ok) {
24461                btn.textContent = 'Opened!';
24462                setTimeout(function () { btn.textContent = orig; }, 1800);
24463              }
24464            })
24465            .catch(function () {
24466              btn.textContent = 'Failed';
24467              setTimeout(function () { btn.textContent = orig; }, 2000);
24468            });
24469        });
24470      });
24471
24472      loadSavedTheme();
24473
24474      // ── Compact number formatting for stat chips ──────────────────────────
24475      (function(){
24476        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();}
24477        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24478          var raw=parseInt(chip.getAttribute('data-raw'),10);
24479          if(isNaN(raw))return;
24480          var valEl=chip.querySelector('.stat-chip-val');
24481          if(valEl)valEl.textContent=fmt(raw);
24482          var exactEl=chip.querySelector('.stat-chip-exact');
24483          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24484        });
24485        // Code density chip
24486        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24487          var code=parseInt(chip.getAttribute('data-code'),10);
24488          var phys=parseInt(chip.getAttribute('data-physical'),10);
24489          if(isNaN(code)||isNaN(phys)||phys===0)return;
24490          var pct=(code/phys*100).toFixed(1)+'%';
24491          var valEl=chip.querySelector('.stat-chip-val');
24492          if(valEl)valEl.textContent=pct;
24493        });
24494        // Populate author handle from data-author attribute
24495        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24496          var author=chip.getAttribute('data-author');
24497          var el=chip.querySelector('.author-handle');
24498          if(el)el.textContent='/'+author.replace(/\s+/g,'');
24499        });
24500        // Click-to-copy on run-id-chip elements
24501        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24502          chip.addEventListener('click',function(){
24503            var val=chip.getAttribute('data-copy');
24504            if(!val)return;
24505            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24506            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);}
24507            chip.classList.add('chip-copied-flash');
24508            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24509          });
24510        });
24511        // Format delta card values with data-raw using comma-separated full numbers
24512        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24513          var raw=parseInt(card.getAttribute('data-raw'),10);
24514          if(isNaN(raw))return;
24515          var valEl=card.querySelector('.delta-card-val');
24516          if(valEl)valEl.textContent=raw.toLocaleString();
24517        });
24518        // Format code-before / code-now numbers in the compare banner stats line
24519        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24520          var raw=parseInt(el.getAttribute('data-raw'),10);
24521          if(!isNaN(raw))el.textContent=raw.toLocaleString();
24522        });
24523      })();
24524
24525      // ── Shared tooltip for all result-page charts ─────────────────────────
24526      var rTT=(function(){
24527        var el=document.getElementById('r-tt');
24528        if(!el)return{s:function(){},h:function(){},m:function(){}};
24529        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24530        function hide(){el.style.display='none';}
24531        function move(e){
24532          var x=e.clientX+16,y=e.clientY-12;
24533          var r=el.getBoundingClientRect();
24534          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24535          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24536          el.style.left=x+'px';el.style.top=y+'px';
24537        }
24538        return{s:show,h:hide,m:move};
24539      })();
24540      window.rTT=rTT;
24541
24542      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24543      (function(){
24544        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24545        document.addEventListener('mouseover',function(e){
24546          var t=e.target;
24547          while(t&&t.getAttribute){
24548            var l=t.getAttribute('data-ttl');
24549            if(l!==null){
24550              var v=t.getAttribute('data-ttv')||'';
24551              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24552              return;
24553            }
24554            t=t.parentNode;
24555          }
24556        });
24557        document.addEventListener('mouseout',function(e){
24558          var t=e.target;
24559          while(t&&t.getAttribute){
24560            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24561            t=t.parentNode;
24562          }
24563        });
24564        document.addEventListener('mousemove',function(e){
24565          var el=document.getElementById('r-tt');
24566          if(el&&el.style.display!=='none')rTT.m(e);
24567        });
24568        window.addEventListener('blur',function(){rTT.h();});
24569        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24570      })();
24571
24572      // ── Language overview charts ───────────────────────────────────────────
24573      (function(){
24574        var D={{ lang_chart_json|safe }};
24575        if(!D||!D.length)return;
24576        var el=document.getElementById('result-lang-charts');
24577        if(!el)return;
24578        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24579        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24580        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24581        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();}
24582        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24583        function px(n){return Math.round(n);}
24584        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+'"';}
24585        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24586        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24587        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24588        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;}
24589        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24590
24591        // Donut chart — height matches the stacked-bar chart so both panels align
24592        var rHb_d=28;
24593        var DH=Math.max(220,D.length*rHb_d+32);
24594        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24595        var legX=208,DW=395;
24596        var legCount=D.length;
24597        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24598        var legYStart=Math.round((DH-legCount*legSpacing)/2);
24599        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">';
24600        // One shared transition on every donut element so slices, leader lines,
24601        // outside labels, % labels and the legend all animate together as a single
24602        // picture when a language is hovered. Slices scale from the donut centre.
24603        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>';
24604        if(D.length===1){
24605          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24606          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+'"/>';
24607        } else {
24608          var smalls=[];
24609          var ang=-Math.PI/2;
24610          D.forEach(function(d,i){
24611            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24612            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24613            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24614            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24615            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24616            var pct=Math.round(d.code/tot*100);
24617            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"/>';
24618            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]});}
24619            ang+=sw;
24620          });
24621          // Small slices (<5%) get outside labels positioned near each slice's own
24622          // angular position (a slice on the left gets its label/leader on the left),
24623          // then nudged apart horizontally so text never overlaps. Leader lines point
24624          // from each slice to its label. Horizontal text keeps long names legible;
24625          // the whole SVG scales up in Full View so these stay readable there too.
24626          if(smalls.length){
24627            smalls.sort(function(a,b){return a.mAng-b.mAng;});
24628            var sPad=6,sRowY=11;
24629            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)));});
24630            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;}
24631            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24632            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24633            smalls.forEach(function(sm){
24634              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24635              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;"/>';
24636              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>';
24637            });
24638          }
24639        }
24640        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24641        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24642        D.forEach(function(d,i){
24643          var ly=legYStart+i*legSpacing;
24644          var pctL=Math.round(d.code/tot*100);
24645          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24646          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24647          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24648          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24649          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24650          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24651          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>';
24652          ds+='</g>';
24653        });
24654        ds+='</svg>';
24655
24656        // Horizontal stacked-bar chart — fills container width
24657        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24658        var LW=108,BW=260,svgW=LW+BW+68;
24659        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24660        var barBH=Math.min(32,Math.round(barRhb*0.7));
24661        var SH=DH;
24662        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24663        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">';
24664        D.forEach(function(d,i){
24665          var y=barTopPad+i*barRhb,x=LW;
24666          var phys=d.physical||d.code+d.comments+d.blanks;
24667          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24668          var lmid=y+barBH/2+4;
24669          // Combined breakdown shown when hovering the row, the language name, or the
24670          // total at the bar end (\n becomes a line break in the tooltip).
24671          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24672          bs+='<g class="lang-bar-row">';
24673          // Hit area ends just past the total label so empty space to the right of the
24674          // bar does not trigger the tooltip — only the name, bar and total are hot.
24675          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24676          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24677          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>';
24678          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;}
24679          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;}
24680          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>';}
24681          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>';
24682          bs+='</g>';
24683        });
24684        var ly=SH-14;
24685        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24686        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24687        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24688        var totAll=totC+totCm+totBl||1;
24689        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24690        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24691        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24692        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24693        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24694        bs+='<g data-kind="code" style="cursor:pointer;">'
24695          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24696          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24697          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24698          +'</g>';
24699        bs+='<g data-kind="comment" style="cursor:pointer;">'
24700          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24701          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24702          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24703          +'</g>';
24704        bs+='<g data-kind="blank" style="cursor:pointer;">'
24705          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24706          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24707          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24708          +'</g>';
24709        bs+='</svg>';
24710        el.innerHTML='<div class="r-lang-overview">'+
24711          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24712          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24713        '</div>';
24714        function wireDonutLegend(svg){
24715          if(!svg)return;
24716          // Every donut element carries data-lang: slices (path/circle), leader lines,
24717          // outside labels + % labels (text) and legend rows (g). Hovering any one of
24718          // them emphasises that language across all of them and fades the rest, so the
24719          // slice, its leader line, its label and its legend row move as one picture.
24720          var items=svg.querySelectorAll('[data-lang]');
24721          function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24722            var tag=el.tagName.toLowerCase();
24723            if(tag==='path'||tag==='circle'){
24724              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)';}
24725              else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24726              else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24727            }else if(tag==='line'){
24728              if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24729              else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24730              else{el.style.opacity='';el.style.strokeWidth='';}
24731            }else if(tag==='text'){
24732              if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24733              else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24734              else{el.style.opacity='';el.style.fontWeight='';}
24735            }else{ // legend group
24736              if(st===1){el.style.opacity='1';}
24737              else if(st===-1){el.style.opacity='0.4';}
24738              else{el.style.opacity='';}
24739            }
24740          }
24741          function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24742          function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24743          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();});
24744          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();});
24745          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24746        }
24747        function wireMixLegend(svg){
24748          if(!svg)return;
24749          var legGs=svg.querySelectorAll('g[data-kind]');
24750          var allRects=svg.querySelectorAll('rect[data-kind]');
24751          if(!legGs.length)return;
24752          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';}}
24753          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='';}}
24754          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]);}
24755        }
24756        wireDonutLegend(el.querySelector('svg'));
24757        wireMixLegend(el.querySelectorAll('svg')[1]);
24758
24759        // ── Language breakdown Full View expand ─────────────────────────────────
24760        var langOvBtn=document.getElementById('result-lang-overview-expand');
24761        if(langOvBtn){langOvBtn.addEventListener('click',function(){
24762          var src=document.getElementById('result-lang-charts');
24763          if(!src)return;
24764          var overlay=document.createElement('div');
24765          overlay.className='r-chart-modal-overlay';
24766          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>';
24767          document.body.appendChild(overlay);
24768          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24769          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24770          var wrap=document.getElementById('result-lang-overview-modal-wrap');
24771          if(wrap){
24772            wrap.innerHTML=src.innerHTML;
24773            var svgs=wrap.querySelectorAll('svg');
24774            for(var i=0;i<svgs.length;i++){
24775              svgs[i].removeAttribute('width');
24776              svgs[i].removeAttribute('height');
24777              svgs[i].style.cssText='display:block;width:100%;height:auto;';
24778            }
24779            var ov=wrap.querySelector('.r-lang-overview');
24780            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24781            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24782            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24783            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24784            wireDonutLegend(wrap.querySelector('svg'));
24785            wireMixLegend(wrap.querySelectorAll('svg')[1]);
24786            requestAnimationFrame(function(){
24787              var ss=wrap.querySelectorAll('svg');
24788              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%;';}}
24789            });
24790          }
24791        });}
24792      })();
24793
24794      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24795      (function(){
24796        var LANG_D={{ lang_chart_json|safe }};
24797        var SCAT_D={{ scatter_chart_json|safe }};
24798        var SEM_D={{ semantic_chart_json|safe }};
24799        var SUB_D={{ submodule_chart_json|safe }};
24800        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24801        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24802        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();}
24803        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24804        function px(n){return Math.round(n);}
24805        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+'"';}
24806        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24807        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24808        // rather than disappear; the SVG scales up in Full View).
24809        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;}
24810
24811        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24812        function renderCompositionInEl(el,mode,shOvr){
24813          if(!el||!LANG_D||!LANG_D.length)return;
24814          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24815          var LW=110,SH=shOvr||300;
24816          var svgW=Math.max(320,el.offsetWidth||480);
24817          var BW=Math.max(120,svgW-LW-80);
24818          var legendH=24,topPad=4;
24819          var n=LANG_D.length||1;
24820          var rowTotal=Math.floor((SH-legendH-topPad)/n);
24821          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24822          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">';
24823          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24824          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24825          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24826          var totAll2=totC2+totCm2+totBl2||1;
24827          if(mode==='pct'){
24828            LANG_D.forEach(function(d,i){
24829              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24830              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24831              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24832              var lmid=y+Math.floor(bH/2)+4;
24833              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24834              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>';
24835              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;}
24836              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;}
24837              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>';}
24838              var pct=Math.round((d.code||0)/tot2*100);
24839              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>';
24840            });
24841          } else {
24842            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24843            LANG_D.forEach(function(d,i){
24844              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24845              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24846              var lmid=y+Math.floor(bH/2)+4;
24847              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));
24848              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>';
24849              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;}
24850              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;}
24851              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>';}
24852              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>';
24853            });
24854          }
24855          var ly=SH-legendH+4;
24856          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24857          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24858          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24859          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24860          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24861          s+='<g data-kind="code" style="cursor:pointer;">'
24862            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24863            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24864            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24865            +'</g>';
24866          s+='<g data-kind="comment" style="cursor:pointer;">'
24867            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24868            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24869            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24870            +'</g>';
24871          s+='<g data-kind="blank" style="cursor:pointer;">'
24872            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24873            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24874            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24875            +'</g>';
24876          s+='</svg>';
24877          el.innerHTML=s;
24878          wireMixLegendEl(el);
24879        }
24880        function wireMixLegendEl(container){
24881          var svg=container&&container.querySelector('svg');
24882          if(!svg)return;
24883          var legGs=svg.querySelectorAll('g[data-kind]');
24884          var allRects=svg.querySelectorAll('rect[data-kind]');
24885          if(!legGs.length)return;
24886          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';}}
24887          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='';}}
24888          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]);}
24889        }
24890        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24891        renderComposition('abs');
24892        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24893          btn.addEventListener('click',function(){
24894            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24895            btn.classList.add('active');
24896            renderComposition(btn.getAttribute('data-rcomp'));
24897          });
24898        });
24899
24900        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24901        function wireScatterLegend(container){
24902          var svg=container&&container.querySelector('svg');
24903          if(!svg)return;
24904          var legGs=svg.querySelectorAll('g[data-lang]');
24905          var circs=svg.querySelectorAll('circle[data-lang]');
24906          var labs=svg.querySelectorAll('text[data-lang]');
24907          if(!legGs.length)return;
24908          // Raise an element to the top of its parent so the hovered bubble and its
24909          // name/number labels sit above overlapping neighbours (clustered bubbles
24910          // otherwise bury the one you are trying to read).
24911          function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24912          function hl(lang){
24913            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';}}
24914            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';}}
24915            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24916          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='';}}
24917          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]);}
24918        }
24919        function renderScatterInEl(el,hOvr){
24920          if(!el||!SCAT_D||!SCAT_D.length)return;
24921          var n=SCAT_D.length;
24922          var H=hOvr||300,PL=52,PB=36,PT=44;
24923          var W=Math.max(320,el.offsetWidth||480);
24924          var cH=H-PT-PB;
24925          // Legend: max 2 columns, fills vertical space. The compact card shows the
24926          // top languages by code lines plus a "+N more" row linking to Full View;
24927          // Full View (hOvr set) shows every language across up to 2 tall columns.
24928          var compact=!hOvr;
24929          var availH=Math.max(120,H-24);
24930          var rowsFit=Math.max(2,Math.floor(availH/18));
24931          var legTrunc=compact&&(n>2*rowsFit);
24932          var legShown=legTrunc?(2*rowsFit-1):n;
24933          var legTotal=legTrunc?(2*rowsFit):n;
24934          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24935          var legPerCol=Math.ceil(legTotal/legCols);
24936          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24937          var legColW=hOvr?144:130;
24938          var LG=26;
24939          var legW=legCols*legColW;
24940          var cW=W-PL-LG-legW;
24941          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24942          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24943          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24944          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24945          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24946          var logMaxF=Math.log1p(maxF);
24947          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">';
24948          // Smooth the legend-hover fade so bubbles + labels animate together.
24949          s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24950          // Y grid lines (linear)
24951          [0,0.25,0.5,0.75,1].forEach(function(t){
24952            var y=PT+cH*(1-t);
24953            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24954            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>';
24955          });
24956          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24957          [0,0.25,0.5,0.75,1].forEach(function(t){
24958            var x=PL+cW*t;
24959            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24960            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24961            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>';
24962          });
24963          // Full View (hOvr set) has the vertical room to show the per-bubble value
24964          // line; the compact card shows only the language label to avoid the
24965          // overlapping-label clutter seen when bubbles cluster together.
24966          var showVal=!!hOvr;
24967          SCAT_D.forEach(function(d,i){
24968            // X uses log1p so outlier languages (many files) don't push others to the far left
24969            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24970            var cy2=PT+cH-d.code/maxC*cH;
24971            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24972            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"/>';
24973            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24974            if(showVal){
24975              var ty2=Math.max(24,px(cy2)-px(r)-3);
24976              var ty1=Math.max(12,ty2-14);
24977              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>';
24978              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>';
24979            }else{
24980              var ly2=Math.max(12,px(cy2)-px(r)-3);
24981              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>';
24982            }
24983          });
24984          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>';
24985          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>';
24986          // Legend (right side — top languages, max 2 columns, fills height)
24987          var legX=PL+cW+LG;
24988          var legBlockH=legPerCol*legRowH;
24989          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24990          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24991          for(var lk=0;lk<legShown;lk++){
24992            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24993            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24994            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;">';
24995            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24996            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24997            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>';
24998            s+='</g>';
24999          }
25000          if(legTrunc){
25001            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25002            s+='<g data-more="1" style="cursor:pointer;">';
25003            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25004            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25005            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>';
25006            s+='</g>';
25007          }
25008          s+='</svg>';
25009          el.innerHTML=s;
25010          wireScatterLegend(el);
25011          var moreEl=el.querySelector('g[data-more]');
25012          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25013        }
25014        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25015
25016        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25017        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25018        // the old vertical column layout on wide containers.
25019        function renderSemanticInEl(el,key,sh){
25020          if(!el||!SEM_D||!SEM_D.length)return;
25021          var n2=SEM_D.length||1;
25022          var LW=112,SH=sh||Math.max(180,n2*28+26);
25023          var svgW=Math.max(320,el.offsetWidth||480);
25024          var BW=Math.max(120,svgW-LW-80);
25025          var topPad=4,botPad=14;
25026          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25027          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25028          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25029          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">';
25030          SEM_D.forEach(function(d,i){
25031            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25032            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>';
25033            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"/>';
25034            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>';
25035          });
25036          s+='</svg>';
25037          el.innerHTML=s;
25038        }
25039        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25040        var semSel=document.getElementById('r-semantic-metric');
25041        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25042        var semExpand=document.getElementById('r-semantic-expand');
25043        if(semExpand){
25044          semExpand.addEventListener('click',function(){
25045            var key=semSel?semSel.value:'functions';
25046            var n=SEM_D.length||1;
25047            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25048            var modalH=Math.min(Math.max(360,n*38+60),maxH);
25049            var overlay=document.createElement('div');
25050            overlay.className='r-chart-modal-overlay';
25051            var optHtml=
25052              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25053              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25054              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25055              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25056              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25057            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>';
25058            document.body.appendChild(overlay);
25059            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25060            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25061            var modalEl=document.getElementById('r-sem-modal-chart');
25062            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25063            var modalSel=document.getElementById('r-sem-modal-metric');
25064            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25065          });
25066        }
25067
25068        // ── Expand buttons: re-render charts at large size inside modal ──────────
25069        (function(){
25070          function makeExpandModal(title,mH,subtitle,ctrlHtml){
25071            var overlay=document.createElement('div');
25072            overlay.className='r-chart-modal-overlay';
25073            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25074            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25075            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>';
25076            document.body.appendChild(overlay);
25077            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25078            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25079            return overlay.querySelector('.r-expand-modal-chart');
25080          }
25081          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25082          var compExpandBtn=document.getElementById('r-composition-expand');
25083          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25084            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25085            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25086            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25087              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25088            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25089            if(wrap){
25090              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25091              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25092                btn.addEventListener('click',function(){
25093                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25094                  btn.classList.add('active');
25095                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25096                });
25097              });
25098            }
25099          });}
25100          var scatExpandBtn=document.getElementById('r-scatter-expand');
25101          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25102            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25103            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25104          });}
25105          var densExpandBtn=document.getElementById('r-density-expand');
25106          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25107            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25108            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25109            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25110          });}
25111          var avgExpandBtn=document.getElementById('r-avglines-expand');
25112          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25113            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25114            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25115            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25116          });}
25117          var subExpandBtn=document.getElementById('r-submodule-expand');
25118          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25119            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25120            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25121            var metCtrl=
25122              '<select class="r-chart-select" id="r-sub-modal-metric">'
25123              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25124              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25125              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25126              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25127              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25128              +'</select>';
25129            var sortCtrl=
25130              '<select class="r-chart-select" id="r-sub-modal-sort">'
25131              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25132              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25133              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25134              +'</select>';
25135            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25136            if(wrap){
25137              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25138              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25139              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25140              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25141              if(mSub)mSub.addEventListener('change',reRenderSub);
25142              if(mSort)mSort.addEventListener('change',reRenderSub);
25143            }
25144          });}
25145        })();
25146
25147        // ── Comment Density: comments / (code + comments) per language ───────────
25148        function renderDensityInEl(el,shOvr){
25149          if(!el||!LANG_D||!LANG_D.length)return;
25150          var n=LANG_D.length||1;
25151          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25152          var svgW=Math.max(320,el.offsetWidth||480);
25153          var BW=Math.max(120,svgW-LW-80);
25154          var topPad=4,botPad=26;
25155          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25156          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25157          var densities=LANG_D.map(function(d){
25158            var sig=(d.code||0)+(d.comments||0);
25159            return sig>0?(d.comments||0)/sig:0;
25160          });
25161          var maxDen=Math.max.apply(null,densities)||1;
25162          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">';
25163          LANG_D.forEach(function(d,i){
25164            var den=densities[i],bw=den/maxDen*BW;
25165            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25166            var pct=Math.round(den*100);
25167            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>';
25168            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"/>';
25169            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25170            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>';
25171          });
25172          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>';
25173          s+='</svg>';
25174          el.innerHTML=s;
25175        }
25176        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25177        renderDensity();
25178
25179        // ── Avg Lines per File: code / files per language ─────────────────────
25180        function renderAvgLinesInEl(el,shOvr){
25181          if(!el||!LANG_D||!LANG_D.length)return;
25182          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25183          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25184          var n=data.length||1;
25185          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25186          var svgW=Math.max(320,el.offsetWidth||480);
25187          var BW=Math.max(120,svgW-LW-80);
25188          var topPad=4,botPad=26;
25189          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25190          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25191          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25192          var maxAvg=Math.max.apply(null,avgs)||1;
25193          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">';
25194          data.forEach(function(d,i){
25195            var avg=avgs[i],bw=avg/maxAvg*BW;
25196            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25197            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>';
25198            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"/>';
25199            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25200            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>';
25201          });
25202          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>';
25203          s+='</svg>';
25204          el.innerHTML=s;
25205        }
25206        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25207        renderAvgLines();
25208
25209        // ── Repository Overview: overall row + per-submodule rows ────────────
25210        function renderSubmoduleInEl(el,key,sort,shOvr){
25211          if(!el)return;
25212          var overall={
25213            name:'Overall',
25214            code:{{ code_lines }},
25215            comment:{{ comment_lines }},
25216            blank:{{ blank_lines }},
25217            physical:{{ physical_lines }},
25218            files:{{ files_analyzed }},
25219            isOverall:true
25220          };
25221          var subs=SUB_D.slice();
25222          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25223          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25224          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25225          var data=[overall].concat(subs);
25226          var sepH=subs.length>0?14:0;
25227          var naturalH=data.length*32+sepH+16;
25228          var SH=shOvr||Math.max(100,naturalH);
25229          var svgW=Math.max(320,el.offsetWidth||480);
25230          var LW=116,BW=Math.max(200,svgW-LW-54);
25231          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25232          var OVERALL_COL='#6b7280';
25233          var topPad=4,botPad=8;
25234          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25235          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25236          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">';
25237          var yOff=topPad;
25238          data.forEach(function(d,i){
25239            var v=d[key]||0,bw=v/maxV*BW;
25240            var y=yOff+Math.floor((rowSlot-bH)/2);
25241            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25242            var label=d.name||d.path||'?';
25243            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>';
25244            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25245            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25246            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>';
25247            yOff+=rowSlot;
25248            if(d.isOverall&&subs.length>0){
25249              yOff+=sepH;
25250            }
25251          });
25252          s+='</svg>';
25253          el.innerHTML=s;
25254        }
25255        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25256        var subSel=document.getElementById('r-sub-metric');
25257        var sortSel=document.getElementById('r-sub-sort');
25258        renderSubmodule('code','desc');
25259        if(subSel){
25260          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25261          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25262        }
25263
25264        // Equalise heights within each chart row: if one chart in a grid row is taller
25265        // than its neighbour, re-render the shorter one at the taller height so bars fill
25266        // the available vertical space instead of leaving a gap.
25267        function syncRowHeights(){
25268          var avgEl=document.getElementById('r-avglines-chart');
25269          var subEl=document.getElementById('r-submodule-chart');
25270          if(avgEl&&subEl){
25271            var avgSvg=avgEl.querySelector('svg');
25272            var subSvg=subEl.querySelector('svg');
25273            if(avgSvg&&subSvg){
25274              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25275              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25276              var key=subSel?subSel.value||'code':'code';
25277              var sort=sortSel?sortSel.value:'desc';
25278              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25279              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25280            }
25281          }
25282          var semEl=document.getElementById('r-semantic-chart');
25283          var denEl=document.getElementById('r-density-chart');
25284          if(semEl&&denEl){
25285            var semSvg=semEl.querySelector('svg');
25286            var denSvg=denEl.querySelector('svg');
25287            if(semSvg&&denSvg){
25288              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25289              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25290              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25291              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25292            }
25293          }
25294        }
25295        syncRowHeights();
25296
25297        // Re-render all SVG charts when the window is resized so bars fill the card.
25298        var _rResizeTimer;
25299        window.addEventListener('resize',function(){
25300          clearTimeout(_rResizeTimer);
25301          _rResizeTimer=setTimeout(function(){
25302            var rcompBtn=document.querySelector('[data-rcomp].active');
25303            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25304            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25305            if(semSel)renderSemantic(semSel.value||'functions');
25306            renderDensity();
25307            renderAvgLines();
25308            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25309            syncRowHeights();
25310          },120);
25311        });
25312      })();
25313
25314      (function randomizeWatermarks() {
25315        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25316        if (!wms.length) return;
25317        var placed = [];
25318        function tooClose(top, left) {
25319          for (var i = 0; i < placed.length; i++) {
25320            var dt = Math.abs(placed[i][0] - top);
25321            var dl = Math.abs(placed[i][1] - left);
25322            if (dt < 20 && dl < 18) return true;
25323          }
25324          return false;
25325        }
25326        function pick(leftBand) {
25327          for (var attempt = 0; attempt < 50; attempt++) {
25328            var top = Math.random() * 85 + 5;
25329            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25330            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25331          }
25332          var top = Math.random() * 85 + 5;
25333          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25334          placed.push([top, left]);
25335          return [top, left];
25336        }
25337        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25338        var half = Math.floor(wms.length / 2);
25339        wms.forEach(function (img, i) {
25340          var pos = pick(i < half);
25341          var size = Math.floor(Math.random() * 100 + 160);
25342          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25343          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25344          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;
25345        });
25346      })();
25347
25348      (function spawnCodeParticles() {
25349        var container = document.getElementById('code-particles');
25350        if (!container) return;
25351        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'];
25352        for (var i = 0; i < 38; i++) {
25353          (function(idx) {
25354            var el = document.createElement('span');
25355            el.className = 'code-particle';
25356            el.textContent = snippets[idx % snippets.length];
25357            var left = Math.random() * 94 + 2;
25358            var top = Math.random() * 88 + 6;
25359            var dur = (Math.random() * 10 + 9).toFixed(1);
25360            var delay = (Math.random() * 18).toFixed(1);
25361            var rot = (Math.random() * 26 - 13).toFixed(1);
25362            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25363            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';
25364            container.appendChild(el);
25365          })(i);
25366        }
25367      })();
25368
25369      {% if pdf_generating %}
25370      // Poll for PDF readiness and swap the disabled button to a live link once done.
25371      (function() {
25372        var openBtn = document.getElementById('pdf-open-btn');
25373        var dlBtn = document.getElementById('pdf-download-btn');
25374        function checkPdf() {
25375          fetch('/api/runs/{{ run_id }}/pdf-status')
25376            .then(function(r) { return r.json(); })
25377            .then(function(d) {
25378              if (d.ready) {
25379                if (openBtn) {
25380                  var a = document.createElement('a');
25381                  a.className = 'button';
25382                  a.id = 'pdf-open-btn';
25383                  a.href = '/runs/pdf/{{ run_id }}';
25384                  a.target = '_blank';
25385                  a.rel = 'noopener';
25386                  a.textContent = 'Open PDF';
25387                  openBtn.replaceWith(a);
25388                }
25389                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25390              } else {
25391                setTimeout(checkPdf, 3000);
25392              }
25393            })
25394            .catch(function() { setTimeout(checkPdf, 5000); });
25395        }
25396        setTimeout(checkPdf, 3000);
25397      })();
25398      {% endif %}
25399
25400    })();
25401  </script>
25402  <script nonce="{{ csp_nonce }}">
25403  (function(){
25404    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'}];
25405    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);});}
25406    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25407    function init(){
25408      var btn=document.getElementById('settings-btn');if(!btn)return;
25409      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25410      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>';
25411      document.body.appendChild(m);
25412      var g=document.getElementById('scheme-grid');
25413      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);});
25414      var cl=document.getElementById('settings-close');
25415      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);});})();
25416      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');});
25417      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25418      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25419    }
25420    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25421  }());
25422  </script>
25423  <footer class="site-footer">
25424    local code analysis - metrics, history and reports
25425    &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>
25426    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25427    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25428    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25429    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25430  </footer>
25431  {% if confluence_configured %}
25432  <script nonce="{{ csp_nonce }}">
25433  (function() {
25434    var postBtn = document.getElementById('postConfluenceBtn');
25435    var copyBtn = document.getElementById('copyWikiBtn');
25436    var modal   = document.getElementById('confluenceModal');
25437    if (!postBtn || !modal) return;
25438
25439    postBtn.addEventListener('click', function() {
25440      document.getElementById('confStatus').style.display = 'none';
25441      modal.style.display = 'flex';
25442    });
25443    document.getElementById('confCancelBtn').addEventListener('click', function() {
25444      modal.style.display = 'none';
25445    });
25446    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25447
25448    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25449      var btn = this;
25450      btn.disabled = true;
25451      var status = document.getElementById('confStatus');
25452      status.style.display = 'block';
25453      status.style.background = '#dbeafe';
25454      status.style.color = '#1e40af';
25455      status.textContent = 'Posting to Confluence\u2026';
25456      var resp = await fetch('/api/confluence/post', {
25457        method: 'POST',
25458        headers: { 'Content-Type': 'application/json' },
25459        body: JSON.stringify({
25460          run_id: '{{ run_id }}',
25461          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25462          report_url: document.getElementById('confReportUrl').value.trim() || null
25463        })
25464      });
25465      var data = await resp.json();
25466      if (data.ok) {
25467        status.style.background = '#dcfce7'; status.style.color = '#166534';
25468        status.textContent = 'Posted! Page ID: ' + data.page_id;
25469      } else {
25470        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25471        status.textContent = 'Error: ' + (data.error || 'Unknown error');
25472      }
25473      btn.disabled = false;
25474    });
25475
25476    if (copyBtn) {
25477      copyBtn.addEventListener('click', async function() {
25478        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25479        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25480        var text = await resp.text();
25481        try {
25482          await navigator.clipboard.writeText(text);
25483          var orig = copyBtn.textContent;
25484          copyBtn.textContent = 'Copied!';
25485          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25486        } catch(e) {
25487          alert('Clipboard write failed \u2014 check browser permissions.');
25488        }
25489      });
25490    }
25491  })();
25492  </script>
25493  {% endif %}
25494  <script nonce="{{ csp_nonce }}">
25495  (function() {
25496    var deleteBtn = document.getElementById('delete-run-btn');
25497    var modal     = document.getElementById('delete-run-modal');
25498    var cancelBtn = document.getElementById('delete-run-cancel');
25499    var confirmBtn= document.getElementById('delete-run-confirm');
25500    if (!deleteBtn || !modal) return;
25501    deleteBtn.addEventListener('click', function() {
25502      document.getElementById('delete-run-status').style.display = 'none';
25503      modal.style.display = 'flex';
25504    });
25505    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25506    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25507    confirmBtn.addEventListener('click', async function() {
25508      confirmBtn.disabled = true;
25509      cancelBtn.disabled = true;
25510      var status = document.getElementById('delete-run-status');
25511      status.style.display = 'block';
25512      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25513      status.textContent = 'Deleting\u2026';
25514      try {
25515        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25516        if (resp.status === 204 || resp.ok) {
25517          status.style.background = '#dcfce7'; status.style.color = '#166534';
25518          status.textContent = 'Deleted. Redirecting\u2026';
25519          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25520        } else {
25521          var d = await resp.json().catch(function(){return {};});
25522          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25523          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25524          confirmBtn.disabled = false;
25525          cancelBtn.disabled = false;
25526        }
25527      } catch (e) {
25528        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25529        status.textContent = 'Network error: ' + String(e);
25530        confirmBtn.disabled = false;
25531        cancelBtn.disabled = false;
25532      }
25533    });
25534  })();
25535  </script>
25536  <script nonce="{{ csp_nonce }}">(function(){
25537    var bundleBtn = document.getElementById('download-bundle-btn');
25538    if (bundleBtn) {
25539      bundleBtn.addEventListener('click', function() {
25540        bundleBtn.disabled = true;
25541        var orig = bundleBtn.textContent;
25542        bundleBtn.textContent = 'Preparing\u2026';
25543        fetch('/api/runs/{{ run_id }}/bundle')
25544          .then(function(r) {
25545            if (!r.ok) throw new Error('HTTP ' + r.status);
25546            return r.blob();
25547          })
25548          .then(function(blob) {
25549            var url = URL.createObjectURL(blob);
25550            var a = document.createElement('a');
25551            a.href = url;
25552            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25553            document.body.appendChild(a);
25554            a.click();
25555            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25556            bundleBtn.disabled = false;
25557            bundleBtn.textContent = orig;
25558          })
25559          .catch(function(e) {
25560            bundleBtn.disabled = false;
25561            bundleBtn.textContent = orig;
25562            alert('Bundle download failed: ' + String(e));
25563          });
25564      });
25565    }
25566  })();</script>
25567  <script nonce="{{ csp_nonce }}">(function(){
25568    var dot=document.getElementById('status-dot');
25569    var pingEl=document.getElementById('server-ping-ms');
25570    var tipEl=document.getElementById('server-tip-ping');
25571    var fm=document.getElementById('footer-mode');
25572    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)';}}
25573    function doPing(){
25574      var t0=performance.now();
25575      fetch('/healthz',{cache:'no-store'})
25576        .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);})
25577        .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)';}});
25578    }
25579    doPing();
25580    setInterval(doPing,5000);
25581    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');}
25582  })();</script>
25583  <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>
25584  {% if let Some(banner) = report_header_footer %}
25585  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25586  {% endif %}
25587</body>
25588</html>
25589"##,
25590    ext = "html"
25591)]
25592// Template structs need many bool fields to pass Askama rendering flags.
25593#[allow(clippy::struct_excessive_bools)]
25594struct ResultTemplate {
25595    version: &'static str,
25596    report_title: String,
25597    project_path: String,
25598    output_dir: String,
25599    run_id: String,
25600    files_analyzed: u64,
25601    files_skipped: u64,
25602    physical_lines: u64,
25603    code_lines: u64,
25604    comment_lines: u64,
25605    blank_lines: u64,
25606    mixed_lines: u64,
25607    functions: u64,
25608    classes: u64,
25609    variables: u64,
25610    imports: u64,
25611    html_url: Option<String>,
25612    pdf_url: Option<String>,
25613    json_url: Option<String>,
25614    html_download_url: Option<String>,
25615    pdf_download_url: Option<String>,
25616    json_download_url: Option<String>,
25617    html_path: Option<String>,
25618    json_path: Option<String>,
25619    prev_run_id: Option<String>,
25620    prev_run_timestamp: Option<String>,
25621    prev_run_code_lines: Option<u64>,
25622    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
25623    prev_fa_str: String,
25624    prev_fs_str: String,
25625    prev_pl_str: String,
25626    prev_cl_str: String,
25627    prev_cml_str: String,
25628    prev_bl_str: String,
25629    // Signed change column for main metrics
25630    delta_fa_str: String,
25631    delta_fa_class: String,
25632    delta_fs_str: String,
25633    delta_fs_class: String,
25634    delta_pl_str: String,
25635    delta_pl_class: String,
25636    delta_cl_str: String,
25637    delta_cl_class: String,
25638    delta_cml_str: String,
25639    delta_cml_class: String,
25640    delta_bl_str: String,
25641    delta_bl_class: String,
25642    // delta vs previous scan
25643    delta_lines_added: Option<i64>,
25644    delta_lines_removed: Option<i64>,
25645    delta_lines_net_str: String,
25646    delta_lines_net_class: String,
25647    delta_files_added: Option<usize>,
25648    delta_files_removed: Option<usize>,
25649    delta_files_modified: Option<usize>,
25650    delta_files_unchanged: Option<usize>,
25651    delta_files_total: Option<usize>,
25652    delta_unmodified_lines: Option<u64>,
25653    // git context
25654    git_branch: Option<String>,
25655    git_branch_url: Option<String>,
25656    git_commit: Option<String>,
25657    git_commit_long: Option<String>,
25658    git_author: Option<String>,
25659    git_commit_url: Option<String>,
25660    // scan metadata for hero section
25661    scan_performed_by: String,
25662    scan_time_display: String,
25663    scan_time_utc_ms: i64,
25664    os_display: String,
25665    test_count: u64,
25666    // reserve "pad" card, revealed by JS only when the visible card count is odd
25667    test_assertion_count: u64,
25668    // history
25669    prev_scan_count: usize,
25670    current_scan_number: usize,
25671    // submodule breakdown (empty when not requested)
25672    submodule_rows: Vec<SubmoduleRow>,
25673    scan_config_url: String,
25674    lang_chart_json: String,
25675    // Askama reads these via proc-macro expansion; clippy can't trace through it.
25676    #[allow(dead_code)]
25677    scatter_chart_json: String,
25678    #[allow(dead_code)]
25679    semantic_chart_json: String,
25680    #[allow(dead_code)]
25681    submodule_chart_json: String,
25682    #[allow(dead_code)]
25683    has_submodule_data: bool,
25684    #[allow(dead_code)]
25685    has_semantic_data: bool,
25686    pdf_generating: bool,
25687    csp_nonce: String,
25688    /// Whether Confluence integration is configured — shows Post button when true.
25689    confluence_configured: bool,
25690    server_mode: bool,
25691    /// Header/footer identification banner, mirrored from the HTML/PDF report.
25692    report_header_footer: Option<String>,
25693    run_id_short: String,
25694    /// True when rendering a static offline file (index.html); hides server-only actions.
25695    #[allow(dead_code)]
25696    is_offline: bool,
25697    /// Total cyclomatic complexity score across all analyzed files.
25698    cyclomatic_complexity: u64,
25699    /// Logical SLOC (statement count) when available; None for unsupported languages.
25700    lsloc: Option<u64>,
25701    /// Unique Lines of Code across all analyzed files.
25702    uloc: u64,
25703    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
25704    dryness_pct_str: String,
25705    /// Number of duplicate file groups detected.
25706    duplicate_group_count: usize,
25707    /// Whether a COCOMO estimate is available to display.
25708    has_cocomo: bool,
25709    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
25710    cocomo_effort_str: String,
25711    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
25712    cocomo_duration_str: String,
25713    /// Pre-formatted average team size, e.g. "2.32".
25714    cocomo_staff_str: String,
25715    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
25716    cocomo_ksloc_str: String,
25717    /// COCOMO mode label shown in the card (e.g. "Organic").
25718    cocomo_mode_label: String,
25719    /// Tooltip text explaining the selected COCOMO mode.
25720    cocomo_mode_tooltip: String,
25721    /// Per-file complexity alert threshold. 0 = off (no highlighting).
25722    complexity_alert: u32,
25723    /// Whether any file has coverage data attached.
25724    has_coverage_data: bool,
25725    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
25726    cov_line_pct: String,
25727    /// Overall function coverage percentage string — empty if no data.
25728    cov_fn_pct: String,
25729    /// Overall branch coverage percentage string — empty if no branch data.
25730    cov_branch_pct: String,
25731    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
25732    cov_lines_summary: String,
25733}
25734
25735#[derive(Template)]
25736#[template(
25737    source = r##"
25738<!doctype html>
25739<html lang="en">
25740<head>
25741  <meta charset="utf-8">
25742  <meta name="viewport" content="width=device-width, initial-scale=1">
25743  <title>OxideSLOC | Analyzing…</title>
25744  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25745  <style nonce="{{ csp_nonce }}">
25746    :root {
25747      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25748      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25749      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25750      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25751    }
25752    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25753    *{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;}
25754    .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);}
25755    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25756    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25757    .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));}
25758    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25759    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25760    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25761    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25762    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25763    @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; } }
25764    .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;}
25765    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25766    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25767    .page-body{padding:32px 24px 36px;}
25768    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25769    .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;}
25770    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25771    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25772    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25773    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25774    .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;}
25775    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25776    .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;}
25777    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25778    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25779    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25780    .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;}
25781    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25782    .hidden{display:none!important;}
25783    .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;}
25784    .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;}
25785    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25786    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25787    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25788    .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);}
25789    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25790    .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;}
25791    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25792    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25793    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25794    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25795    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25796    .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;}
25797    @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));}}
25798    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25799    .site-footer a{color:var(--muted);}
25800    .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;}
25801    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25802    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25803    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25804  </style>
25805</head>
25806<body>
25807  <div class="background-watermarks" aria-hidden="true">
25808    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25809    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25810    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25811    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25812    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25813    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25814  </div>
25815  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25816  <nav class="top-nav">
25817    <div class="top-nav-inner">
25818      <a href="/" class="brand">
25819        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25820        <div class="brand-copy">
25821          <h1 class="brand-title">OxideSLOC</h1>
25822          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25823        </div>
25824      </a>
25825      <div class="nav-right">
25826        <a class="nav-pill" href="/">Home</a>
25827        <div class="nav-dropdown">
25828          <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>
25829          <div class="nav-dropdown-menu">
25830            <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>
25831          </div>
25832        </div>
25833        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25834        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25835        <div class="nav-dropdown">
25836          <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>
25837          <div class="nav-dropdown-menu">
25838            <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>
25839          </div>
25840        </div>
25841        <div class="server-status-wrap" id="server-status-wrap">
25842          <div class="nav-pill server-online-pill" id="server-status-pill">
25843            <span class="status-dot" id="status-dot"></span>
25844            <span id="server-status-label">Server</span>
25845            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25846          </div>
25847          <div class="server-status-tip">
25848            OxideSLOC is running — accessible on your network.
25849            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25850          </div>
25851        </div>
25852        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25853          <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>
25854        </button>
25855        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25856          <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>
25857          <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>
25858        </button>
25859      </div>
25860    </div>
25861  </nav>
25862  <div class="page-body">
25863    <div class="wait-panel">
25864      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25865      <h2 class="wait-title">Analyzing your project…</h2>
25866      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25867      <div class="path-block">{{ project_path }}</div>
25868      <div class="metrics-row">
25869        <div class="metric-card">
25870          <div class="metric-label">Elapsed</div>
25871          <div class="metric-value" id="elapsed">0s</div>
25872        </div>
25873        <div class="metric-card">
25874          <div class="metric-label">Phase</div>
25875          <div class="metric-value" id="phase">Starting</div>
25876        </div>
25877        <div class="metric-card hidden" id="files-card">
25878          <div class="metric-label">Files</div>
25879          <div class="metric-value" id="files-progress">0</div>
25880        </div>
25881      </div>
25882      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25883      <div class="warn-slow hidden" id="warn-slow">
25884        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.
25885      </div>
25886      <div class="err-panel hidden" id="err-panel">
25887        <strong>Analysis failed</strong>
25888        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25889      </div>
25890      <div class="actions hidden" id="actions">
25891        <a href="/scan" class="btn-primary">Try Again</a>
25892        <a href="/view-reports" class="btn-outline">View Reports</a>
25893      </div>
25894    </div>
25895  </div>
25896  <script nonce="{{ csp_nonce }}">
25897    (function() {
25898      var WAIT_ID = {{ wait_id_json|safe }};
25899      var startTime = Date.now();
25900      var pollInterval = 1500;
25901      var retries = 0;
25902      var maxRetries = 5;
25903      var warnShown = false;
25904
25905      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();}
25906
25907      function elapsed() {
25908        return Math.floor((Date.now() - startTime) / 1000);
25909      }
25910
25911      function updateElapsed() {
25912        var s = elapsed();
25913        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25914      }
25915
25916      function setPhase(txt) {
25917        document.getElementById('phase').textContent = txt;
25918      }
25919
25920      var elapsedTimer = setInterval(updateElapsed, 1000);
25921
25922      function poll() {
25923        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25924          .then(function(r) {
25925            if (!r.ok) throw new Error('HTTP ' + r.status);
25926            return r.json();
25927          })
25928          .then(function(data) {
25929            retries = 0;
25930            if (data.state === 'complete') {
25931              clearInterval(elapsedTimer);
25932              setPhase('Done');
25933              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25934            } else if (data.state === 'failed') {
25935              clearInterval(elapsedTimer);
25936              setPhase('Failed');
25937              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25938              document.getElementById('err-panel').classList.remove('hidden');
25939              document.getElementById('actions').classList.remove('hidden');
25940            } else {
25941              // still running
25942              var s = elapsed();
25943              if (s > 90 && !warnShown) {
25944                warnShown = true;
25945                document.getElementById('warn-slow').classList.remove('hidden');
25946              }
25947              setPhase(data.phase || 'Running');
25948              var fd = data.files_done || 0, ft = data.files_total || 0;
25949              if (ft > 0) {
25950                var card = document.getElementById('files-card');
25951                if (card) card.classList.remove('hidden');
25952                var fp = document.getElementById('files-progress');
25953                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25954              }
25955              setTimeout(poll, pollInterval);
25956            }
25957          })
25958          .catch(function(err) {
25959            retries++;
25960            if (retries >= maxRetries) {
25961              clearInterval(elapsedTimer);
25962              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25963              document.getElementById('err-panel').classList.remove('hidden');
25964              document.getElementById('actions').classList.remove('hidden');
25965            } else {
25966              // exponential back-off capped at 8s
25967              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25968            }
25969          });
25970      }
25971
25972      setTimeout(poll, pollInterval);
25973
25974      // If the browser restores this page from bfcache (Back after viewing results),
25975      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25976      window.addEventListener("pageshow", function(e) {
25977        if (e.persisted) { setTimeout(poll, 200); }
25978      });
25979    })();
25980  </script>
25981  <footer class="site-footer">
25982    local code analysis - metrics, history and reports
25983    &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>
25984    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25985    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25986    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25987    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25988  </footer>
25989  <script nonce="{{ csp_nonce }}">
25990    (function(){
25991      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25992      if(s==="dark")b.classList.add("dark-theme");
25993      var tt=document.getElementById("theme-toggle");
25994      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25995    })();
25996    (function spawnCodeParticles(){
25997      var c=document.getElementById('code-particles');if(!c)return;
25998      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'];
25999      for(var i=0;i<32;i++){(function(idx){
26000        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26001        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26002        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26003        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26004        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26005        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26006        c.appendChild(el);
26007      })(i);}
26008    })();
26009    (function randomizeWatermarks(){
26010      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26011      var placed=[];
26012      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;}
26013      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];}
26014      var half=Math.floor(wms.length/2);
26015      wms.forEach(function(img,i){
26016        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26017        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26018        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26019        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26020        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26021        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26022      });
26023    })();
26024  </script>
26025  <script nonce="{{ csp_nonce }}">
26026  (function(){
26027    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'}];
26028    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);});}
26029    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26030    function init(){
26031      var btn=document.getElementById('settings-btn');if(!btn)return;
26032      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26033      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>';
26034      document.body.appendChild(m);
26035      var g=document.getElementById('scheme-grid');
26036      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);});
26037      var cl=document.getElementById('settings-close');
26038      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);});})();
26039      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');});
26040      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26041      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26042    }
26043    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26044  }());
26045  </script>
26046  <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]';
26047  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;}
26048  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>
26049</body>
26050</html>
26051"##,
26052    ext = "html"
26053)]
26054struct ScanWaitTemplate {
26055    version: &'static str,
26056    wait_id_json: String,
26057    project_path: String,
26058    csp_nonce: String,
26059}
26060
26061#[derive(Template)]
26062#[template(
26063    source = r##"
26064<!doctype html>
26065<html lang="en">
26066<head>
26067  <meta charset="utf-8">
26068  <meta name="viewport" content="width=device-width, initial-scale=1">
26069  <title>OxideSLOC | Error</title>
26070  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26071  <style nonce="{{ csp_nonce }}">
26072    :root {
26073      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26074      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26075      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26076      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26077    }
26078    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26079    *{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;}
26080    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26081    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26082    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26083    .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);}
26084    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26085    .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));}
26086    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26087    .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;}
26088    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26089    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26090    @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; } }
26091    .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;}
26092    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26093    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26094    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26095    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26096    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26097    .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;}
26098    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26099    .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);}
26100    .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;}
26101    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26102    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26103    .settings-modal-body{padding:14px 16px 16px;}
26104    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26105    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26106    .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;}
26107    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26108    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26109    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26110    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26111    .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;}
26112    .tz-select:focus{border-color:var(--oxide);}
26113    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26114    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26115    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26116    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26117    .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;}
26118    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26119    .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);}
26120    .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;}
26121    .btn-secondary:hover{background:var(--line);}
26122    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26123    .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;}
26124    .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;}
26125    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26126    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26127    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26128    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26129    .bug-report-panel.open{display:flex;}
26130    .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;}
26131    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26132    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26133    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26134    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26135    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26136    .br-network-badge.online .br-net-dot{background:#2a6846;}
26137    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26138    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26139    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26140    .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;}
26141    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26142    .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;}
26143    .btn-sm:hover{background:var(--line);}
26144    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26145    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26146    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26147    .bug-report-hint a:hover{text-decoration:underline;}
26148    .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;}
26149    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26150    .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;}
26151    .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;}
26152    .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;}
26153    @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));}}
26154    .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;}
26155  </style>
26156</head>
26157<body>
26158  <div class="background-watermarks" aria-hidden="true">
26159    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26160    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26161    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26162    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26163    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26164    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26165  </div>
26166  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26167  <div class="top-nav">
26168    <div class="top-nav-inner">
26169      <a class="brand" href="/">
26170        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26171        <div class="brand-copy">
26172          <div class="brand-title">OxideSLOC</div>
26173          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26174        </div>
26175      </a>
26176      <div class="nav-right">
26177        <a class="nav-pill" href="/">Home</a>
26178        <div class="nav-dropdown">
26179          <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>
26180          <div class="nav-dropdown-menu">
26181            <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>
26182          </div>
26183        </div>
26184        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26185        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26186        <div class="nav-dropdown">
26187          <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>
26188          <div class="nav-dropdown-menu">
26189            <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>
26190          </div>
26191        </div>
26192        <div class="server-status-wrap" id="server-status-wrap">
26193          <div class="nav-pill server-online-pill" id="server-status-pill">
26194            <span class="status-dot" id="status-dot"></span>
26195            <span id="server-status-label">Server</span>
26196            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26197          </div>
26198          <div class="server-status-tip">
26199            OxideSLOC is running — accessible on your network.
26200            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26201          </div>
26202        </div>
26203        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26204          <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>
26205        </button>
26206        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26207          <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>
26208          <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>
26209        </button>
26210      </div>
26211    </div>
26212  </div>
26213
26214  <div class="page">
26215    <div class="panel">
26216      <h1>Error</h1>
26217      <div class="error-box" id="error-msg-text">{{ message }}</div>
26218      <div id="br-meta" hidden
26219        data-version="{{ version }}"
26220        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26221        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26222      <div class="actions">
26223        <a class="btn-primary" href="/scan">Back to setup</a>
26224        {% if let Some(report_url) = last_report_url %}
26225        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26226        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26227        {% else %}
26228        <a class="btn-secondary" href="/view-reports">View Reports</a>
26229        {% endif %}
26230      </div>
26231      <div class="bug-report-section" id="bug-report-section">
26232        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26233          <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>
26234          Generate Bug Report
26235          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26236        </button>
26237        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26238          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
26239          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
26240          <div class="bug-report-btns">
26241            <button type="button" class="btn-sm" id="bug-report-copy">
26242              <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>
26243              Copy to clipboard
26244            </button>
26245            <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;">
26246              <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>
26247              Open GitHub Issue
26248            </a>
26249            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26250              <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>
26251              Save as file
26252            </button>
26253          </div>
26254          <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>
26255          <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>
26256        </div>
26257      </div>
26258    </div>
26259  </div>
26260  <footer class="site-footer">
26261    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26262    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26263    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26264    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26265    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26266  </footer>
26267  <script nonce="{{ csp_nonce }}">(function(){
26268    var meta=document.getElementById('br-meta');
26269    var pre=document.getElementById('bug-report-pre');
26270    var copyBtn=document.getElementById('bug-report-copy');
26271    var trigger=document.getElementById('bug-report-trigger');
26272    var panel=document.getElementById('bug-report-panel');
26273    var networkBadge=document.getElementById('br-network-badge');
26274    var networkLabel=document.getElementById('br-network-label');
26275    var ghLink=document.getElementById('bug-report-github-link');
26276    var saveBtn=document.getElementById('bug-report-save');
26277    var hintOnline=document.getElementById('br-hint-online');
26278    var hintOffline=document.getElementById('br-hint-offline');
26279    if(!meta||!pre)return;
26280    var ver=meta.getAttribute('data-version')||'';
26281    var runId=meta.getAttribute('data-run-id')||'';
26282    var code=meta.getAttribute('data-error-code')||'';
26283    var msgEl=document.getElementById('error-msg-text');
26284    var msg=msgEl?msgEl.textContent.trim():'';
26285    function getBrowser(){
26286      var ua=navigator.userAgent;
26287      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26288      if(!m)return 'Unknown browser';
26289      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26290      return n+' '+m[2];
26291    }
26292    var lines=['oxide-sloc Bug Report','==============================',''];
26293    lines.push('App version:  v'+ver);
26294    if(code)lines.push('HTTP status:  '+code);
26295    if(runId)lines.push('Run ID:       '+runId);
26296    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
26297    lines.push('Timestamp:    '+new Date().toISOString());
26298    lines.push('Browser:      '+getBrowser());
26299    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
26300    lines.push('');
26301    lines.push('Error message:');
26302    lines.push(msg);
26303    lines.push('');
26304    lines.push('Steps to reproduce:');
26305    lines.push('  1. ');
26306    lines.push('');
26307    lines.push('Expected behavior:');
26308    lines.push('  ');
26309    pre.textContent=lines.join('\n');
26310    function applyNetwork(online){
26311      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26312      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26313      if(ghLink){
26314        if(online){
26315          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26316          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26317        }
26318        ghLink.style.display=online?'inline-flex':'none';
26319      }
26320      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26321      if(hintOnline)hintOnline.style.display=online?'block':'none';
26322      if(hintOffline)hintOffline.style.display=online?'none':'block';
26323    }
26324    applyNetwork(navigator.onLine);
26325    var probed=false;
26326    function probeNetwork(){
26327      if(probed)return;probed=true;
26328      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26329      var probeIdx=0;
26330      function tryNext(){
26331        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26332        var u=probeUrls[probeIdx++];
26333        var c2=new AbortController();
26334        var t2=setTimeout(function(){c2.abort();},4000);
26335        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26336          .then(function(){clearTimeout(t2);applyNetwork(true);})
26337          .catch(function(){clearTimeout(t2);tryNext();});
26338      }
26339      tryNext();
26340    }
26341    if(trigger&&panel){
26342      trigger.addEventListener('click',function(){
26343        var open=panel.classList.toggle('open');
26344        trigger.classList.toggle('open',open);
26345        trigger.setAttribute('aria-expanded',open?'true':'false');
26346        if(open)probeNetwork();
26347      });
26348    }
26349    if(copyBtn){
26350      copyBtn.addEventListener('click',function(){
26351        var txt=pre.textContent;
26352        if(navigator.clipboard&&navigator.clipboard.writeText){
26353          navigator.clipboard.writeText(txt).then(function(){
26354            copyBtn.textContent='\u2713 Copied!';
26355            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);
26356          });
26357        }else{
26358          var ta=document.createElement('textarea');
26359          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26360          document.body.appendChild(ta);ta.select();
26361          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26362          document.body.removeChild(ta);
26363        }
26364      });
26365    }
26366    if(saveBtn){
26367      saveBtn.addEventListener('click',function(){
26368        var txt=pre.textContent;
26369        var blob=new Blob([txt],{type:'text/plain'});
26370        var url=URL.createObjectURL(blob);
26371        var a=document.createElement('a');
26372        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26373        document.body.appendChild(a);a.click();
26374        document.body.removeChild(a);URL.revokeObjectURL(url);
26375      });
26376    }
26377  })();</script>
26378  <script nonce="{{ csp_nonce }}">
26379    (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");});})();
26380    (function spawnCodeParticles() {
26381      var container = document.getElementById('code-particles');
26382      if (!container) return;
26383      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'];
26384      for (var i = 0; i < 38; i++) {
26385        (function(idx) {
26386          var el = document.createElement('span');
26387          el.className = 'code-particle';
26388          el.textContent = snippets[idx % snippets.length];
26389          var left = Math.random() * 94 + 2;
26390          var top = Math.random() * 88 + 6;
26391          var dur = (Math.random() * 10 + 9).toFixed(1);
26392          var delay = (Math.random() * 18).toFixed(1);
26393          var rot = (Math.random() * 26 - 13).toFixed(1);
26394          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26395          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';
26396          container.appendChild(el);
26397        })(i);
26398      }
26399    })();
26400    (function randomizeWatermarks() {
26401      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26402      var placed = [];
26403      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; }
26404      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]; }
26405      var half = Math.floor(wms.length/2);
26406      wms.forEach(function(img, i) {
26407        var pos = pick(i < half);
26408        var w = Math.floor(Math.random()*60+80);
26409        var rot = (Math.random()*40-20).toFixed(1);
26410        var op = (Math.random()*0.08+0.05).toFixed(2);
26411        var animDur = (Math.random()*6+5).toFixed(1);
26412        var animDelay = (Math.random()*10).toFixed(1);
26413        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';
26414      });
26415    })();
26416  </script>
26417  <script nonce="{{ csp_nonce }}">
26418  (function(){
26419    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'}];
26420    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);});}
26421    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26422    function init(){
26423      var btn=document.getElementById('settings-btn');if(!btn)return;
26424      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26425      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>';
26426      document.body.appendChild(m);
26427      var g=document.getElementById('scheme-grid');
26428      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);});
26429      var cl=document.getElementById('settings-close');
26430      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);});})();
26431      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');});
26432      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26433      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26434    }
26435    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26436  }());
26437  </script>
26438  <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]';
26439  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;}
26440  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>
26441</body>
26442</html>
26443"##,
26444    ext = "html"
26445)]
26446struct ErrorTemplate {
26447    message: String,
26448    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
26449    last_report_url: Option<String>,
26450    /// Label for the secondary action button; defaults to "View last report" when None.
26451    last_report_label: Option<String>,
26452    /// Run ID to surface in the bug report; `None` when not applicable.
26453    run_id: Option<String>,
26454    /// HTTP status code to surface in the bug report; `None` when unknown.
26455    error_code: Option<u16>,
26456    csp_nonce: String,
26457    version: &'static str,
26458}
26459
26460// ── LocateFileTemplate ────────────────────────────────────────────────────────
26461
26462#[derive(Template)]
26463#[template(
26464    source = r##"
26465<!doctype html>
26466<html lang="en">
26467<head>
26468  <meta charset="utf-8">
26469  <meta name="viewport" content="width=device-width, initial-scale=1">
26470  <title>OxideSLOC | Locate Report</title>
26471  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26472  <style nonce="{{ csp_nonce }}">
26473    :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);}
26474    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26475    *{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;}
26476    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26477    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26478    .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);}
26479    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26480    .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));}
26481    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26482    .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;}
26483    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26484    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26485    @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;}}
26486    .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;}
26487    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26488    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26489    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26490    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26491    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26492    .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;}
26493    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26494    .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);}
26495    .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;}
26496    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26497    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26498    .settings-modal-body{padding:14px 16px 16px;}
26499    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26500    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26501    .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;}
26502    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26503    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26504    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26505    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26506    .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;}
26507    .tz-select:focus{border-color:var(--oxide);}
26508    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26509    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26510    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26511    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26512    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26513    .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;}
26514    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26515    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26516    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26517    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26518    .locate-row{display:flex;gap:8px;align-items:stretch;}
26519    .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;}
26520    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26521    body.dark-theme .locate-input{background:var(--surface-2);}
26522    .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;}
26523    .warning-banner.show{display:flex;}
26524    .warning-banner svg{flex:0 0 auto;}
26525    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26526    .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;}
26527    .error-inline.show{display:flex;}
26528    .error-inline svg{flex:0 0 auto;margin-top:2px;}
26529    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26530    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26531    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26532    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26533    .err-kv-p{margin:0 0 4px;}
26534    .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;}
26535    .success-inline.show{display:flex;}
26536    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26537    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26538    .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;}
26539    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26540    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26541    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26542    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26543    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26544    .fh-row:last-child{border-bottom:none;}
26545    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26546    .fh-dir{font-weight:800;color:var(--text);}
26547    .fh-hl{color:var(--oxide);font-weight:700;}
26548    .fh-muted{color:var(--muted);}
26549    .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;}
26550    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26551    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26552    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26553    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26554    .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;}
26555    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26556    .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;}
26557    .btn-secondary:hover{background:var(--line);}
26558    .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;}
26559    .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;}
26560    .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;}
26561    @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));}}
26562    .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;}
26563    .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;}
26564    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26565  </style>
26566</head>
26567<body>
26568  <div class="background-watermarks" aria-hidden="true">
26569    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26570    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26571    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26572    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26573    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26574    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26575  </div>
26576  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26577  <div class="top-nav">
26578    <div class="top-nav-inner">
26579      <a class="brand" href="/">
26580        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26581        <div class="brand-copy">
26582          <div class="brand-title">OxideSLOC</div>
26583          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26584        </div>
26585      </a>
26586      <div class="nav-right">
26587        <a class="nav-pill" href="/">Home</a>
26588        <div class="nav-dropdown">
26589          <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>
26590          <div class="nav-dropdown-menu">
26591            <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>
26592          </div>
26593        </div>
26594        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26595        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26596        <div class="nav-dropdown">
26597          <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>
26598          <div class="nav-dropdown-menu">
26599            <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>
26600          </div>
26601        </div>
26602        <div class="server-status-wrap" id="server-status-wrap">
26603          <div class="nav-pill server-online-pill" id="server-status-pill">
26604            <span class="status-dot" id="status-dot"></span>
26605            <span id="server-status-label">Server</span>
26606            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26607          </div>
26608          <div class="server-status-tip">
26609            OxideSLOC is running &mdash; accessible on your network.
26610            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26611          </div>
26612        </div>
26613        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26614          <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>
26615        </button>
26616        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26617          <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>
26618          <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>
26619        </button>
26620      </div>
26621    </div>
26622  </div>
26623
26624  <div class="page">
26625    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26626    <div class="panel">
26627      <h1>Report File Not Found</h1>
26628      <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>
26629      <div class="field-label">Missing file</div>
26630      <div class="filename-chip">
26631        <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>
26632        {{ expected_filename }}
26633      </div>
26634      <div class="locate-section">
26635        <h2>Locate Scan Output Folder</h2>
26636        <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>
26637        <p>OxideSLOC will find the correct files inside automatically.</p>
26638        <div class="locate-row">
26639          <input type="text" id="locate-file-input"
26640                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26641                 class="locate-input" autocomplete="off" spellcheck="false">
26642          {% if !server_mode %}
26643          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
26644          {% endif %}
26645        </div>
26646        <div class="warning-banner" id="filename-warning">
26647          <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>
26648          <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>
26649        </div>
26650        <div class="error-inline" id="locate-error">
26651          <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>
26652          <span id="locate-error-text"></span>
26653        </div>
26654        <div class="success-inline" id="locate-success">
26655          <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>
26656          <span>Scan restored &mdash; loading report&hellip;</span>
26657        </div>
26658        <div class="btn-row">
26659          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26660          <a class="btn-secondary" href="/view-reports">View Reports</a>
26661        </div>
26662        <div class="folder-hint-shell">
26663          <div class="folder-hint-hdr">
26664            <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>
26665            Expected Folder Structure &mdash; Select the Top-Level Folder
26666          </div>
26667          <div class="folder-hint-body">
26668            <div class="fh-row">
26669              <span class="fh-tog">&#9658;</span>
26670              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
26671              <span class="fh-badge">&larr; select this</span>
26672            </div>
26673            <div class="fh-row fh-i1">
26674              <span class="fh-tog">&#9658;</span>
26675              <span class="fh-dir">html/</span>
26676            </div>
26677            <div class="fh-row fh-i2">
26678              <span class="fh-bul">&#8226;</span>
26679              <span class="fh-hl">{{ expected_filename }}</span>
26680            </div>
26681            <div class="fh-row fh-i1">
26682              <span class="fh-tog">&#9658;</span>
26683              <span class="fh-dir">json/</span>
26684            </div>
26685            <div class="fh-row fh-i2">
26686              <span class="fh-bul">&#8226;</span>
26687              <span class="fh-muted">result_*.json</span>
26688            </div>
26689            <div class="fh-row fh-i1">
26690              <span class="fh-tog">&#9658;</span>
26691              <span class="fh-dir">pdf/</span>
26692            </div>
26693            <div class="fh-row fh-i2">
26694              <span class="fh-bul">&#8226;</span>
26695              <span class="fh-muted">report_*.pdf</span>
26696            </div>
26697            <div class="fh-row fh-i1">
26698              <span class="fh-tog">&#9658;</span>
26699              <span class="fh-dir">excel/</span>
26700            </div>
26701            <div class="fh-row fh-i2">
26702              <span class="fh-bul">&#8226;</span>
26703              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
26704            </div>
26705          </div>
26706        </div>
26707      </div>
26708    </div>
26709  </div>
26710  <footer class="site-footer">
26711    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26712    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26713    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26714    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26715    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26716  </footer>
26717  <script nonce="{{ csp_nonce }}">(function(){
26718    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26719    if(s==="dark")b.classList.add("dark-theme");
26720    document.getElementById("theme-toggle").addEventListener("click",function(){
26721      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26722    });
26723  })();</script>
26724  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26725    var c=document.getElementById('code-particles');if(!c)return;
26726    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'];
26727    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);}
26728  })();
26729  (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>
26730  <script nonce="{{ csp_nonce }}">(function(){
26731    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'}];
26732    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);});}
26733    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26734    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');});}
26735    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26736  }());</script>
26737  <script nonce="{{ csp_nonce }}">(function(){
26738    var meta=document.getElementById('locate-meta');
26739    var inp=document.getElementById('locate-file-input');
26740    var browseBtn=document.getElementById('browse-locate-btn');
26741    var submitBtn=document.getElementById('locate-submit-btn');
26742    var warning=document.getElementById('filename-warning');
26743    var errBox=document.getElementById('locate-error');
26744    var errText=document.getElementById('locate-error-text');
26745    var okBox=document.getElementById('locate-success');
26746    var expected=meta?meta.getAttribute('data-expected'):'';
26747    var runId=meta?meta.getAttribute('data-run-id'):'';
26748    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26749    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26750    function showErr(msg){
26751      if(errText){
26752        errText.innerHTML='';
26753        var lines=msg.split('\n');
26754        var hasPairs=lines.some(function(l){return / : /.test(l);});
26755        if(!hasPairs){errText.textContent=msg;}
26756        else{
26757          var frag=document.createDocumentFragment();var tbl=null;
26758          lines.forEach(function(line){
26759            var m=line.match(/^(.*?) : (.*)$/);
26760            if(m){
26761              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26762              var tr=document.createElement('tr');
26763              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26764              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26765              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26766            } else {
26767              tbl=null;
26768              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26769            }
26770          });
26771          errText.appendChild(frag);
26772        }
26773      }
26774      if(errBox)errBox.classList.add('show');
26775      if(okBox)okBox.classList.remove('show');
26776    }
26777    function clearErr(){
26778      if(errBox)errBox.classList.remove('show');
26779      if(okBox)okBox.classList.remove('show');
26780    }
26781    function validate(){
26782      var val=inp?inp.value.trim():'';
26783      clearErr();
26784      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26785      if(submitBtn)submitBtn.disabled=false;
26786      if(warning){
26787        var name=basename(val);
26788        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26789        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26790        else warning.classList.remove('show');
26791      }
26792    }
26793    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26794    if(browseBtn){
26795      browseBtn.addEventListener('click',function(){
26796        browseBtn.disabled=true;browseBtn.textContent='...';
26797        fetch('/pick-directory')
26798          .then(function(r){return r.ok?r.json():{cancelled:true};})
26799          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26800          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26801      });
26802    }
26803    if(submitBtn){
26804      submitBtn.addEventListener('click',function(){
26805        var folder=inp?inp.value.trim():'';
26806        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26807        clearErr();
26808        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26809        var body=new URLSearchParams();
26810        body.set('file_path',folder);
26811        body.set('redirect_url',redirectUrl);
26812        body.set('expected_run_id',runId);
26813        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26814          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26815          .then(function(d){
26816            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26817            if(d&&d.ok){
26818              if(okBox)okBox.classList.add('show');
26819              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26820            } else {
26821              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26822            }
26823          })
26824          .catch(function(e){
26825            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26826            showErr('Network error: '+String(e));
26827          });
26828      });
26829    }
26830  })();</script>
26831  <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>
26832</body>
26833</html>
26834"##,
26835    ext = "html"
26836)]
26837struct LocateFileTemplate {
26838    run_id: String,
26839    artifact_type: String,
26840    expected_filename: String,
26841    server_mode: bool,
26842    csp_nonce: String,
26843    version: &'static str,
26844}
26845
26846// ── RelocateScanTemplate ──────────────────────────────────────────────────────
26847
26848#[derive(Template)]
26849#[template(
26850    source = r##"
26851<!doctype html>
26852<html lang="en">
26853<head>
26854  <meta charset="utf-8">
26855  <meta name="viewport" content="width=device-width, initial-scale=1">
26856  <title>OxideSLOC | Locate Scan Files</title>
26857  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26858  <style nonce="{{ csp_nonce }}">
26859    :root {
26860      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26861      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26862      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26863      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26864    }
26865    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26866    *{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;}
26867    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26868    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26869    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26870    .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);}
26871    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26872    .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));}
26873    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26874    .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;}
26875    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26876    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26877    @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;}}
26878    .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;}
26879    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26880    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26881    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26882    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26883    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26884    .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;}
26885    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26886    .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);}
26887    .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;}
26888    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26889    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26890    .settings-modal-body{padding:14px 16px 16px;}
26891    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26892    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26893    .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;}
26894    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26895    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26896    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26897    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26898    .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;}
26899    .tz-select:focus{border-color:var(--oxide);}
26900    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26901    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26902    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26903    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26904    .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;}
26905    .error-box.hidden{display:none;}
26906    .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;}
26907    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26908    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26909    .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;}
26910    .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;}
26911    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26912    .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;}
26913    .btn-secondary:hover{background:var(--line);}
26914    .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;}
26915    .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;}
26916    .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;}
26917    @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));}}
26918    .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;}
26919    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26920    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26921    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26922    .relocate-row{display:flex;gap:8px;align-items:stretch;}
26923    .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;}
26924    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26925    body.dark-theme .relocate-input{background:var(--surface-2);}
26926  </style>
26927</head>
26928<body>
26929  <div class="background-watermarks" aria-hidden="true">
26930    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26931    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26932    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26933    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26934    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26935    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26936  </div>
26937  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26938  <div class="top-nav">
26939    <div class="top-nav-inner">
26940      <a class="brand" href="/">
26941        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26942        <div class="brand-copy">
26943          <div class="brand-title">OxideSLOC</div>
26944          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26945        </div>
26946      </a>
26947      <div class="nav-right">
26948        <a class="nav-pill" href="/">Home</a>
26949        <div class="nav-dropdown">
26950          <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>
26951          <div class="nav-dropdown-menu">
26952            <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>
26953          </div>
26954        </div>
26955        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26956        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26957        <div class="nav-dropdown">
26958          <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>
26959          <div class="nav-dropdown-menu">
26960            <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>
26961          </div>
26962        </div>
26963        <div class="server-status-wrap" id="server-status-wrap">
26964          <div class="nav-pill server-online-pill" id="server-status-pill">
26965            <span class="status-dot" id="status-dot"></span>
26966            <span id="server-status-label">Server</span>
26967            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26968          </div>
26969          <div class="server-status-tip">
26970            OxideSLOC is running — accessible on your network.
26971            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26972          </div>
26973        </div>
26974        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26975          <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>
26976        </button>
26977        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26978          <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>
26979          <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>
26980        </button>
26981      </div>
26982    </div>
26983  </div>
26984
26985  <div class="page">
26986    <div class="panel">
26987      <h1>Scan Files Moved</h1>
26988      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26989      <div class="error-box" id="relocate-error-box">{{ message }}</div>
26990      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
26991      <div class="relocate-section">
26992        <h2>Locate Scan Output</h2>
26993        <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>
26994        <div class="relocate-row">
26995          <input type="text" id="relocate-folder" name="folder_path"
26996                 value="{{ folder_hint }}"
26997                 placeholder="Path to folder containing scan output..."
26998                 class="relocate-input" autocomplete="off" spellcheck="false">
26999          {% if !server_mode %}
27000          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
27001          {% endif %}
27002        </div>
27003        <div style="margin-top:12px;">
27004          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27005        </div>
27006      </div>
27007      <div class="actions">
27008        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27009        <a class="btn-secondary" href="/view-reports">View Reports</a>
27010      </div>
27011    </div>
27012  </div>
27013  <footer class="site-footer">
27014    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
27015    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27016    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27017    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27018    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27019  </footer>
27020  <script nonce="{{ csp_nonce }}">
27021    (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");});})();
27022    (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);}})();
27023    (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;});})();
27024  </script>
27025  <script nonce="{{ csp_nonce }}">
27026  (function(){
27027    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'}];
27028    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);});}
27029    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27030    function init(){
27031      var btn=document.getElementById('settings-btn');if(!btn)return;
27032      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27033      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>';
27034      document.body.appendChild(m);
27035      var g=document.getElementById('scheme-grid');
27036      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);});
27037      var cl=document.getElementById('settings-close');
27038      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);});})();
27039      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');});
27040      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27041      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27042    }
27043    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27044  }());
27045  (function(){
27046    var browseBtn=document.getElementById('browse-relocate-btn');
27047    if(browseBtn){
27048      browseBtn.addEventListener('click',function(){
27049        browseBtn.disabled=true;browseBtn.textContent='...';
27050        var inp=document.getElementById('relocate-folder');
27051        var hint=inp?inp.value:'';
27052        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
27053          .then(function(r){return r.ok?r.json():{cancelled:true};})
27054          .then(function(d){
27055            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27056            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27057          })
27058          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27059      });
27060    }
27061    var restoreBtn=document.getElementById('restore-btn');
27062    var errBox=document.getElementById('relocate-error-box');
27063    var okBox=document.getElementById('relocate-success-box');
27064    if(restoreBtn){
27065      restoreBtn.addEventListener('click',function(){
27066        var inp=document.getElementById('relocate-folder');
27067        var folder=inp?inp.value.trim():'';
27068        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27069        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27070        var body=new URLSearchParams();
27071        body.set('run_id','{{ run_id }}');
27072        body.set('redirect_url','{{ redirect_url }}');
27073        body.set('folder_path',folder);
27074        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27075          .then(function(r){return r.json();})
27076          .then(function(d){
27077            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27078            if(d&&d.ok){
27079              if(errBox)errBox.classList.add('hidden');
27080              if(okBox){okBox.style.display='block';}
27081              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27082            } else {
27083              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27084            }
27085          })
27086          .catch(function(e){
27087            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27088            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27089          });
27090      });
27091    }
27092  }());
27093  </script>
27094  <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]';
27095  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;}
27096  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>
27097</body>
27098</html>
27099"##,
27100    ext = "html"
27101)]
27102struct RelocateScanTemplate {
27103    message: String,
27104    run_id: String,
27105    folder_hint: String,
27106    redirect_url: String,
27107    server_mode: bool,
27108    csp_nonce: String,
27109    version: &'static str,
27110}
27111
27112// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
27113
27114#[derive(Template)]
27115#[template(
27116    source = r##"
27117<!doctype html>
27118<html lang="en">
27119<head>
27120  <meta charset="utf-8">
27121  <meta name="viewport" content="width=device-width, initial-scale=1">
27122  <title>OxideSLOC | View Reports</title>
27123  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27124  <style nonce="{{ csp_nonce }}">
27125    :root {
27126      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27127      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27128      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27129      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27130      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27131    }
27132    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; }
27133    *{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;}
27134    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27135    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27136    .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);}
27137    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27138    .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));}
27139    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27140    .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;}
27141    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27142    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27143    @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; } }
27144    .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;}
27145    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27146    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27147    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27148    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27149    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27150    .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;}
27151    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27152    .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);}
27153    .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;}
27154    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27155    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27156    .settings-modal-body{padding:14px 16px 16px;}
27157    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27158    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27159    .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;}
27160    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27161    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27162    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27163    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27164    .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;}
27165    .tz-select:focus{border-color:var(--oxide);}
27166    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27167    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27168    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27169    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27170    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27171    .panel-meta{font-size:13px;color:var(--muted);}
27172    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27173    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27174    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27175    .per-page-label{font-size:13px;color:var(--muted);}
27176    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;}
27177    .filter-input{min-width:180px;cursor:text;}
27178    .table-wrap{width:100%;overflow-x:auto;}
27179    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27180    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;}
27181    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27182    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27183    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27184    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27185    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27186    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27187    tr:last-child td{border-bottom:none;}
27188    tr:hover td{background:var(--surface-2);}
27189    .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);}
27190    .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);}
27191    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27192    .metric-num{font-weight:700;color:var(--text);}
27193    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27194    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27195    .git-commit-chip{cursor:help;}
27196    .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;}
27197    .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;}
27198    .btn:hover{background:var(--line);}
27199    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27200    .btn.primary:hover{opacity:.9;}
27201    .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;}
27202    .btn-back:hover{background:var(--line);}
27203    .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;}
27204    .export-btn:hover{background:var(--line);}
27205    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27206    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27207    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27208    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27209    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27210    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27211    .pagination-info{font-size:13px;color:var(--muted);}
27212    .pagination-btns{display:flex;gap:6px;}
27213    .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;}
27214    .pg-btn:hover:not(:disabled){background:var(--line);}
27215    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27216    .pg-btn:disabled{opacity:.35;cursor:default;}
27217    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27218    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27219    .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);}
27220    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27221    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27222    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27223    .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);}
27224    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27225    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27226    .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;}
27227    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27228    .site-footer a{color:var(--muted);}
27229    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27230    .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%;}
27231    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27232    .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;}
27233    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27234    .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;}
27235    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27236    .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;}
27237    .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;}
27238    .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;}
27239    @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));}}
27240    .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;}
27241    .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;}
27242    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27243    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27244    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27245    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27246    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27247    .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;}
27248    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27249    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27250    .watched-chip-rm:hover{color:var(--oxide);}
27251    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27252    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27253    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27254    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27255    .rpt-btn{min-width:58px;justify-content:center;}
27256    .flex-row{display:flex;align-items:center;gap:8px;}
27257    .report-cell{overflow:visible;white-space:normal;}
27258    #history-table col:nth-child(1){width:185px;}
27259    #history-table col:nth-child(2){width:220px;}
27260    #history-table col:nth-child(3){width:100px;}
27261    #history-table col:nth-child(4){width:72px;}
27262    #history-table col:nth-child(5){width:82px;}
27263    #history-table col:nth-child(6){width:82px;}
27264    #history-table col:nth-child(7){width:65px;}
27265    #history-table col:nth-child(8){width:90px;}
27266    #history-table col:nth-child(9){width:85px;}
27267    #history-table col:nth-child(10){width:115px;}
27268    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27269    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27270    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27271    .submod-details summary::-webkit-details-marker{display:none;}
27272.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27273    .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;}
27274    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27275    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27276  </style>
27277</head>
27278<body>
27279  <div class="background-watermarks" aria-hidden="true">
27280    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27281    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27282    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27283    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27284    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27285    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27286  </div>
27287  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27288  <div class="top-nav">
27289    <div class="top-nav-inner">
27290      <a class="brand" href="/">
27291        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27292        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27293      </a>
27294      <div class="nav-right">
27295        <a class="nav-pill" href="/">Home</a>
27296        <div class="nav-dropdown">
27297          <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>
27298          <div class="nav-dropdown-menu">
27299            <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>
27300          </div>
27301        </div>
27302        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27303        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27304        <div class="nav-dropdown">
27305          <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>
27306          <div class="nav-dropdown-menu">
27307            <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>
27308          </div>
27309        </div>
27310        <div class="server-status-wrap" id="server-status-wrap">
27311          <div class="nav-pill server-online-pill" id="server-status-pill">
27312            <span class="status-dot" id="status-dot"></span>
27313            <span id="server-status-label">Server</span>
27314            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27315          </div>
27316          <div class="server-status-tip">
27317            OxideSLOC is running — accessible on your network.
27318            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27319          </div>
27320        </div>
27321        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27322          <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>
27323        </button>
27324        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27325          <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>
27326          <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>
27327        </button>
27328      </div>
27329    </div>
27330  </div>
27331
27332  <div class="page">
27333    {% if let Some(err) = browse_error %}
27334    <div class="toast-error">
27335      <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>
27336      {{ err }}
27337    </div>
27338    {% endif %}
27339    {% if linked_count > 0 %}
27340    <div class="toast-success">
27341      <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>
27342      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27343    </div>
27344    {% endif %}
27345    <div class="watched-bar">
27346      <div class="watched-bar-left">
27347        <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>
27348        <span class="watched-label">Watched Folders</span>
27349        <div class="watched-chips">
27350          {% if server_mode %}
27351          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27352          {% else %}
27353          {% for dir in watched_dirs %}
27354          <span class="watched-chip">
27355            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27356            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27357              <input type="hidden" name="folder_path" value="{{ dir }}">
27358              <input type="hidden" name="redirect_to" value="/view-reports">
27359              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27360            </form>
27361          </span>
27362          {% endfor %}
27363          {% if watched_dirs.is_empty() %}
27364          <span class="watched-none">No folders watched — click Choose to add one</span>
27365          {% endif %}
27366          {% endif %}
27367        </div>
27368      </div>
27369      {% if !server_mode %}
27370      <div class="watched-bar-right">
27371        <button type="button" class="btn" id="add-watched-btn">
27372          <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>
27373          Choose
27374        </button>
27375        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27376          <input type="hidden" name="redirect_to" value="/view-reports">
27377          <button type="submit" class="btn">&#8635; Refresh</button>
27378        </form>
27379      </div>
27380      {% endif %}
27381    </div>
27382    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27383      <div class="scan-overlay-card">
27384        <div class="scan-spinner"></div>
27385        <div class="scan-overlay-text">Scanning folder…</div>
27386        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27387      </div>
27388    </div>
27389    <style>
27390    .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);}
27391    .scan-overlay.active{display:flex;}
27392    .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;}
27393    .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;}
27394    @keyframes scanSpin{to{transform:rotate(360deg);}}
27395    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27396    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27397    </style>
27398    {% if total_scans > 0 %}
27399    <div class="summary-strip">
27400      <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>
27401      <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>
27402      <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>
27403      <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>
27404    </div>
27405    {% endif %}
27406
27407    <section class="panel">
27408      <div class="panel-header">
27409        <div>
27410          <h1>View Reports</h1>
27411          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27412          {% 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 %}
27413        </div>
27414        <div class="flex-row">
27415          <button type="button" class="export-btn" id="export-csv-btn">
27416            <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>
27417            Export CSV
27418          </button>
27419          <button type="button" class="export-btn" id="export-xls-btn">
27420            <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>
27421            Export Excel
27422          </button>
27423        </div>
27424      </div>
27425
27426      {% if entries.is_empty() %}
27427      <div class="empty-state">
27428        <strong>No reports with viewable HTML yet</strong>
27429        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.
27430      </div>
27431      {% else %}
27432      <div class="filter-row">
27433        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27434        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27435        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27436      </div>
27437      <div class="table-wrap">
27438        <table id="history-table">
27439          <colgroup>
27440            <col><col><col><col><col><col><col><col><col><col>
27441          </colgroup>
27442          <thead>
27443            <tr id="history-thead">
27444              <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>
27445              <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>
27446              <th>Run ID<div class="col-resize-handle"></div></th>
27447              <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>
27448              <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>
27449              <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>
27450              <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>
27451              <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>
27452              <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>
27453              <th>Report<div class="col-resize-handle"></div></th>
27454            </tr>
27455          </thead>
27456          <tbody id="history-tbody">
27457            {% for entry in entries %}
27458            <tr class="history-row" data-run="{{ entry.run_id }}"
27459                data-timestamp="{{ entry.timestamp }}"
27460                data-project="{{ entry.project_label }}"
27461                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27462                data-skipped="{{ entry.files_skipped }}"
27463                data-comments="{{ entry.comment_lines }}"
27464                data-blank="{{ entry.blank_lines }}"
27465                data-physical="{{ entry.total_physical_lines }}"
27466                data-functions="{{ entry.functions }}"
27467                data-classes="{{ entry.classes }}"
27468                data-variables="{{ entry.variables }}"
27469                data-imports="{{ entry.imports }}"
27470                data-tests="{{ entry.test_count }}"
27471                data-branch="{{ entry.git_branch }}"
27472                data-commit="{{ entry.git_commit }}"
27473                data-has-json="{{ entry.has_json }}"
27474                data-html-url="/runs/html/{{ entry.run_id }}">
27475              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27476              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27477              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27478              <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>
27479              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27480              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27481              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27482              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
27483              <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>
27484              <td class="report-cell">
27485                <div class="actions-cell">
27486                  {% 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 %}
27487                  {% 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 %}
27488                </div>
27489                {% if !entry.submodule_links.is_empty() %}
27490                <details class="submod-details">
27491                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
27492                  <div class="submod-link-list">
27493                    {% for sub in entry.submodule_links %}
27494                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27495                    {% endfor %}
27496                  </div>
27497                </details>
27498                {% endif %}
27499              </td>
27500            </tr>
27501            {% endfor %}
27502          </tbody>
27503        </table>
27504      </div>
27505      <div class="pagination">
27506        <span class="pagination-info" id="pagination-info"></span>
27507        <div class="pagination-btns" id="pagination-btns"></div>
27508        <div class="flex-row">
27509          <span class="per-page-label">Show</span>
27510          <select class="per-page" id="per-page-sel">
27511            <option value="10">10 per page</option>
27512            <option value="25" selected>25 per page</option>
27513            <option value="50">50 per page</option>
27514            <option value="100">100 per page</option>
27515          </select>
27516          <span class="per-page-label" id="page-range-label"></span>
27517        </div>
27518      </div>
27519      {% endif %}
27520    </section>
27521  </div>
27522
27523  <footer class="site-footer">
27524    local code analysis - metrics, history and reports
27525    &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>
27526    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27527    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27528    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27529    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27530  </footer>
27531
27532  <script nonce="{{ csp_nonce }}">
27533    (function () {
27534      // ── Theme ──────────────────────────────────────────────────────────────
27535      var storageKey = 'oxide-sloc-theme';
27536      var body = document.body;
27537      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27538      var toggle = document.getElementById('theme-toggle');
27539      if (toggle) toggle.addEventListener('click', function () {
27540        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27541        body.classList.toggle('dark-theme', next === 'dark');
27542        try { localStorage.setItem(storageKey, next); } catch(e) {}
27543      });
27544
27545      // ── State ─────────────────────────────────────────────────────────────
27546      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27547      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27548      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27549
27550      // Aggregate stats from first (most recent) row
27551      if (allRows.length) {
27552        var first = allRows[0];
27553        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();}
27554        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>':'');}
27555        setChipVal('agg-code', first.dataset.code);
27556        setChipVal('agg-files', first.dataset.files);
27557        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27558        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27559        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(); });
27560      }
27561
27562      // ── Branch filter population ──────────────────────────────────────────
27563      (function() {
27564        var branches = {};
27565        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27566        var sel = document.getElementById('branch-filter');
27567        if (sel) Object.keys(branches).sort().forEach(function(b) {
27568          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27569        });
27570      })();
27571
27572      // ── Filter ────────────────────────────────────────────────────────────
27573      function getFilteredRows() {
27574        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27575        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27576        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27577          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27578          if (branch && (r.dataset.branch || '') !== branch) return false;
27579          return true;
27580        });
27581      }
27582
27583      // ── Pagination ────────────────────────────────────────────────────────
27584      function renderPage() {
27585        var filtered = getFilteredRows();
27586        var total = filtered.length;
27587        var totalPages = Math.max(1, Math.ceil(total / perPage));
27588        currentPage = Math.min(currentPage, totalPages);
27589        var start = (currentPage - 1) * perPage;
27590        var end = Math.min(start + perPage, total);
27591        var shown = {};
27592        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27593        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27594          r.style.display = shown[r.dataset.run] ? '' : 'none';
27595        });
27596        var rl = document.getElementById('page-range-label');
27597        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27598        var info = document.getElementById('pagination-info');
27599        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27600        var btns = document.getElementById('pagination-btns');
27601        if (!btns) return;
27602        btns.innerHTML = '';
27603        function makeBtn(lbl, pg, active, disabled) {
27604          var b = document.createElement('button');
27605          b.className = 'pg-btn' + (active ? ' active' : '');
27606          b.textContent = lbl; b.disabled = disabled;
27607          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27608          return b;
27609        }
27610        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27611        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27612        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27613        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27614      }
27615
27616      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27617      window.applyFilters = function() { currentPage = 1; renderPage(); };
27618
27619      // ── Sorting ───────────────────────────────────────────────────────────
27620      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27621      function doSort(col, type, order) {
27622        var tbody = document.getElementById('history-tbody');
27623        if (!tbody) return;
27624        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27625        rows.sort(function(a, b) {
27626          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27627          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27628          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27629          return va < vb ? 1 : va > vb ? -1 : 0;
27630        });
27631        rows.forEach(function(r) { tbody.appendChild(r); });
27632        currentPage = 1; renderPage();
27633      }
27634      sortHeaders.forEach(function(th) {
27635        th.addEventListener('click', function(e) {
27636          if (e.target.classList.contains('col-resize-handle')) return;
27637          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27638          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27639          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27640          th.classList.add('sort-' + sortOrder);
27641          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27642          doSort(col, type, sortOrder);
27643        });
27644      });
27645
27646      // ── Column resize ─────────────────────────────────────────────────────
27647      (function() {
27648        var table = document.getElementById('history-table');
27649        if (!table) return;
27650        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27651        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27652        ths.forEach(function(th, i) {
27653          var handle = th.querySelector('.col-resize-handle');
27654          if (!handle || !cols[i]) return;
27655          var startX, startW;
27656          handle.addEventListener('mousedown', function(e) {
27657            e.stopPropagation(); e.preventDefault();
27658            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27659            handle.classList.add('dragging');
27660            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27661            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27662            document.addEventListener('mousemove', onMove);
27663            document.addEventListener('mouseup', onUp);
27664          });
27665        });
27666      })();
27667
27668      // ── Full-commit hover tooltip ─────────────────────────────────────────
27669      // The commit chips live inside an overflow:auto table wrapper, which would
27670      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27671      // (escaping the scroll container) and follow the cursor. Event delegation
27672      // keeps it working after pagination/sorting re-renders the rows.
27673      (function() {
27674        var tip = document.createElement('div');
27675        tip.className = 'commit-tip';
27676        tip.setAttribute('role', 'tooltip');
27677        document.body.appendChild(tip);
27678        var shown = false;
27679        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27680        function place(e) {
27681          var pad = 14, r = tip.getBoundingClientRect();
27682          var x = e.clientX + pad, y = e.clientY + pad;
27683          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27684          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27685          tip.style.left = x + 'px'; tip.style.top = y + 'px';
27686        }
27687        function hide() { tip.style.display = 'none'; shown = false; }
27688        document.addEventListener('mouseover', function(e) {
27689          var chip = chipFrom(e.target);
27690          if (!chip) return;
27691          var full = chip.getAttribute('data-full-commit');
27692          if (!full) return;
27693          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27694        });
27695        document.addEventListener('mousemove', function(e) {
27696          if (!shown) return;
27697          if (chipFrom(e.target)) place(e); else hide();
27698        });
27699        document.addEventListener('mouseout', function(e) {
27700          if (chipFrom(e.target)) hide();
27701        });
27702      })();
27703
27704      // ── Reset view ────────────────────────────────────────────────────────
27705      window.resetView = function() {
27706        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27707        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27708        sortCol = null; sortOrder = 'asc';
27709        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27710        var tbody = document.getElementById('history-tbody');
27711        if (tbody) {
27712          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27713          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27714          rows.forEach(function(r) { tbody.appendChild(r); });
27715        }
27716        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27717        var table = document.getElementById('history-table');
27718        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27719        currentPage = 1; renderPage();
27720      };
27721
27722      renderPage();
27723
27724      // ── Export helpers ────────────────────────────────────────────────────
27725      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
27726      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27727      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);}
27728      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;');}
27729      function slocXlsx(fname,sheet,hdrs,rows){
27730        var enc=new TextEncoder();
27731        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;}
27732        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;}
27733        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27734        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27735        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27736        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;}
27737        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27738        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];}
27739        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27740        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27741        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27742          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27743          +'<fonts count="2">'
27744            +'<font><sz val="11"/><name val="Calibri"/></font>'
27745            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27746          +'</fonts>'
27747          +'<fills count="3">'
27748            +'<fill><patternFill patternType="none"/></fill>'
27749            +'<fill><patternFill patternType="gray125"/></fill>'
27750            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27751          +'</fills>'
27752          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27753          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27754          +'<cellXfs count="4">'
27755            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27756            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27757            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27758            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27759          +'</cellXfs>'
27760          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27761          +'</styleSheet>';
27762        var rx='<row r="1">';
27763        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27764        rx+='</row>';
27765        rows.forEach(function(row,ri){
27766          var rn=ri+2;rx+='<row r="'+rn+'">';
27767          row.forEach(function(cell,c){
27768            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27769            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27770            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27771            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27772            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27773            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27774          });
27775          rx+='</row>';
27776        });
27777        var lastCol=hdrs.length,lastRow=rows.length+1;
27778        var tableRef='A1:'+colNm(lastCol)+lastRow;
27779        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27780          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27781          +'<autoFilter ref="'+tableRef+'"/>'
27782          +'<tableColumns count="'+lastCol+'">'
27783          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27784          +'</tableColumns>'
27785          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27786          +'</table>';
27787        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27788          +'<Relationships xmlns="'+pns+'relationships">'
27789          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27790          +'</Relationships>';
27791        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>';
27792        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27793          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27794          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27795          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27796          +'</worksheet>';
27797        var F={
27798          '[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>',
27799          '_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>',
27800          '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>',
27801          '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>',
27802          'xl/styles.xml':stl,
27803          'xl/sharedStrings.xml':ssXml,
27804          'xl/worksheets/sheet1.xml':sh,
27805          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27806          'xl/tables/table1.xml':tableXml
27807        };
27808        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'];
27809        var zparts=[],zcds=[],zoff=0,znf=0;
27810        order.forEach(function(name){
27811          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27812          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]);
27813          var entry=new Uint8Array(lha.length+nb.length+sz);
27814          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27815          zparts.push(entry);
27816          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));
27817          var cde=new Uint8Array(cda.length+nb.length);
27818          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27819          zcds.push(cde);zoff+=entry.length;znf++;
27820        });
27821        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27822        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]);
27823        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27824        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27825        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27826        zout.set(new Uint8Array(ea),zpos);
27827        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27828      }
27829
27830      // Multi-sheet XLSX builder for the scan-history export.
27831      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27832      function slocXlsxMulti(fname,sheets){
27833        var enc=new TextEncoder();
27834        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;}
27835        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;}
27836        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27837        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27838        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27839        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];}
27840        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;}
27841        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27842        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27843        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27844          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27845          +'<fonts count="3">'
27846            +'<font><sz val="11"/><name val="Calibri"/></font>'
27847            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27848            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27849          +'</fonts>'
27850          +'<fills count="4">'
27851            +'<fill><patternFill patternType="none"/></fill>'
27852            +'<fill><patternFill patternType="gray125"/></fill>'
27853            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27854            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27855          +'</fills>'
27856          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27857          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27858          +'<cellXfs count="7">'
27859            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27860            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27861            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27862            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27863            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27864            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27865            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27866          +'</cellXfs>'
27867          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27868          +'</styleSheet>';
27869        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27870        sheets.forEach(function(sh,sheetIdx){
27871          var rx='<row r="1">';
27872          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27873          rx+='</row>';
27874          var rn=2;
27875          sh.rows.forEach(function(row){
27876            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27877            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27878              rx+='<row r="'+rn+'">';
27879              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27880              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27881              rx+='</row>';rn++;return;
27882            }
27883            rx+='<row r="'+rn+'">';
27884            row.forEach(function(cell,c){
27885              var ref=colRef(c,rn);
27886              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27887              if(typeof cell==='object'&&cell!==null){
27888                var cv=cell.v,cs=cell.s!=null?cell.s:0;
27889                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27890                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27891                return;
27892              }
27893              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27894              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27895            });
27896            rx+='</row>';rn++;
27897          });
27898          var cw='';
27899          if(sh.colWidths&&sh.colWidths.length>0){
27900            cw='<cols>';
27901            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27902            cw+='</cols>';
27903          }
27904          var tblParts='';
27905          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27906            tableCounter++;
27907            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27908            var tRef='A1:'+colNm(colCount)+rowCount;
27909            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27910              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27911              +'<autoFilter ref="'+tRef+'"/>'
27912              +'<tableColumns count="'+colCount+'">'
27913              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27914              +'</tableColumns>'
27915              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27916              +'</table>';
27917            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27918              +'<Relationships xmlns="'+pns+'relationships">'
27919              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27920              +'</Relationships>';
27921            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27922          }
27923          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27924            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27925            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27926        });
27927        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>';
27928        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27929        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27930        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>';
27931        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27932        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27933        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27934        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27935          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27936        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27937        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};
27938        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27939        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27940        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27941        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27942        var zparts=[],zcds=[],zoff=0,znf=0;
27943        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++;});
27944        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27945        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]);
27946        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27947        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27948        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27949        zout.set(new Uint8Array(ea),zpos);
27950        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27951      }
27952
27953      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'};
27954      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27955
27956      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'];
27957      function getHistoryRows(){
27958        var r=[];
27959        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27960          var code=Number(tr.getAttribute('data-code'))||0;
27961          var phys=Number(tr.getAttribute('data-physical'))||0;
27962          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27963          r.push([
27964            tr.getAttribute('data-timestamp')||'',
27965            tr.getAttribute('data-project')||'',
27966            tr.getAttribute('data-run')||'',
27967            tr.getAttribute('data-physical')||'',
27968            tr.getAttribute('data-code')||'',
27969            tr.getAttribute('data-comments')||'',
27970            tr.getAttribute('data-blank')||'',
27971            tr.getAttribute('data-files')||'',
27972            tr.getAttribute('data-skipped')||'',
27973            tr.getAttribute('data-functions')||'',
27974            tr.getAttribute('data-classes')||'',
27975            tr.getAttribute('data-variables')||'',
27976            tr.getAttribute('data-imports')||'',
27977            tr.getAttribute('data-tests')||'',
27978            dens,
27979            tr.getAttribute('data-branch')||'',
27980            tr.getAttribute('data-commit')||''
27981          ]);
27982        });
27983        return r;
27984      }
27985      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27986      window.exportHistoryXls = function(){
27987        var histRows=getHistoryRows();
27988        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27989        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]];});
27990        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]};
27991        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27992        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27993        var runId=jsonRow.getAttribute('data-run')||'';
27994        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27995        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27996        fetch('/runs/json/'+runId)
27997          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27998          .then(function(run){
27999            var tot=run.summary_totals||{};
28000            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28001            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28002            function B(v){return{v:v,s:4};}
28003            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28004            var sumRows=[
28005              [{_sec:true,v:'RUN INFORMATION'}],
28006              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28007              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28008              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28009              [B('Branch'),run.git_branch||''],
28010              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28011              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28012              [B('Files Analyzed'),N(tot.files_analyzed)],
28013              [B('Files Skipped'),N(tot.files_skipped)],
28014              [],
28015              [{_sec:true,v:'CODE METRICS'}],
28016              [B('Physical Lines'),N(phys)],
28017              [B('Code Lines'),N(code)],
28018              [B('Comments'),N(tot.comment_lines)],
28019              [B('Blank Lines'),N(tot.blank_lines)],
28020              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28021              [B('Functions'),N(tot.functions)],
28022              [B('Classes / Types'),N(tot.classes)],
28023              [B('Variables'),N(tot.variables)],
28024              [B('Imports'),N(tot.imports)],
28025              [B('Tests'),N(tot.test_count)],
28026              [B('Assertions'),N(tot.test_assertion_count)],
28027              [B('Test Suites'),N(tot.test_suite_count)],
28028              [B('Code Density'),{v:dens,s:6}],
28029              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28030            ];
28031            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28032            var langRows=(run.totals_by_language||[]).map(function(l){
28033              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28034              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28035              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];
28036            });
28037            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28038            var pfRows=(run.per_file_records||[]).map(function(r){
28039              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28040              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];
28041            });
28042            var skHdrs=['File','Status','Size (bytes)'];
28043            var skRows=(run.skipped_file_records||[]).map(function(r){
28044              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28045            });
28046            slocXlsxMulti('scan-history.xlsx',[
28047              histSheet,
28048              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28049              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28050              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28051              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28052            ]);
28053          })
28054          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28055      };
28056
28057      var csvBtn = document.getElementById('export-csv-btn');
28058      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28059      var xlsBtn = document.getElementById('export-xls-btn');
28060      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28061
28062      // ── Remaining CSP-safe event bindings ────────────────────────────────
28063      (function wireEvents() {
28064        var el;
28065        el = document.getElementById('reset-view-btn');
28066        if (el) el.addEventListener('click', window.resetView);
28067        el = document.getElementById('project-filter');
28068        if (el) el.addEventListener('input', window.applyFilters);
28069        el = document.getElementById('branch-filter');
28070        if (el) el.addEventListener('change', window.applyFilters);
28071        el = document.getElementById('per-page-sel');
28072        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28073        (function(){
28074          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');};
28075          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);
28076        })();
28077        el = document.getElementById('add-watched-btn');
28078        if (el) el.addEventListener('click', function() {
28079          fetch('/pick-directory?kind=reports')
28080            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28081            .then(function(data) {
28082              if (!data.cancelled && data.selected_path) {
28083                var form = document.createElement('form');
28084                form.method = 'POST';
28085                form.action = '/watched-dirs/add';
28086                var ri = document.createElement('input');
28087                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28088                var fi = document.createElement('input');
28089                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28090                form.appendChild(ri); form.appendChild(fi);
28091                document.body.appendChild(form);
28092                if (window.__scanOverlay) window.__scanOverlay();
28093                form.submit();
28094              }
28095            })
28096            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28097        });
28098      })();
28099
28100      (function randomizeWatermarks() {
28101        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28102        if (!wms.length) return;
28103        var placed = [];
28104        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;}
28105        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];}
28106        var half=Math.floor(wms.length/2);
28107        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;});
28108      })();
28109
28110      (function spawnCodeParticles() {
28111        var container = document.getElementById('code-particles');
28112        if (!container) return;
28113        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'];
28114        for (var i = 0; i < 38; i++) {
28115          (function(idx) {
28116            var el = document.createElement('span');
28117            el.className = 'code-particle';
28118            el.textContent = snippets[idx % snippets.length];
28119            var left = Math.random() * 94 + 2;
28120            var top = Math.random() * 88 + 6;
28121            var dur = (Math.random() * 10 + 9).toFixed(1);
28122            var delay = (Math.random() * 18).toFixed(1);
28123            var rot = (Math.random() * 26 - 13).toFixed(1);
28124            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28125            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';
28126            container.appendChild(el);
28127          })(i);
28128        }
28129      })();
28130    })();
28131  </script>
28132  <script nonce="{{ csp_nonce }}">
28133  (function(){
28134    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'}];
28135    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);});}
28136    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28137    function init(){
28138      var btn=document.getElementById('settings-btn');if(!btn)return;
28139      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28140      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>';
28141      document.body.appendChild(m);
28142      var g=document.getElementById('scheme-grid');
28143      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);});
28144      var cl=document.getElementById('settings-close');
28145      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);});})();
28146      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');});
28147      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28148      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28149    }
28150    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28151  }());
28152  </script>
28153  <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>
28154</body>
28155</html>
28156"##,
28157    ext = "html"
28158)]
28159struct HistoryTemplate {
28160    version: &'static str,
28161    entries: Vec<HistoryEntryRow>,
28162    total_scans: usize,
28163    linked_count: usize,
28164    browse_error: Option<String>,
28165    watched_dirs: Vec<String>,
28166    csp_nonce: String,
28167    server_mode: bool,
28168}
28169
28170// ── CompareSelectTemplate ──────────────────────────────────────────────────────
28171
28172#[derive(Template)]
28173#[template(
28174    source = r##"
28175<!doctype html>
28176<html lang="en">
28177<head>
28178  <meta charset="utf-8">
28179  <meta name="viewport" content="width=device-width, initial-scale=1">
28180  <title>OxideSLOC | Compare Scans</title>
28181  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28182  <style nonce="{{ csp_nonce }}">
28183    :root {
28184      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28185      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28186      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28187      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28188      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28189    }
28190    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28191    *{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;}
28192    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28193    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28194    .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);}
28195    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28196    .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));}
28197    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28198    .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;}
28199    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28200    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28201    @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; } }
28202    .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;}
28203    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28204    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28205    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28206    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28207    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28208    .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;}
28209    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28210    .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);}
28211    .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;}
28212    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28213    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28214    .settings-modal-body{padding:14px 16px 16px;}
28215    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28216    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28217    .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;}
28218    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28219    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28220    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28221    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28222    .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;}
28223    .tz-select:focus{border-color:var(--oxide);}
28224    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28225    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28226    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28227    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28228    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28229    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28230    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28231    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28232    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28233    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28234    .per-page-label{font-size:13px;color:var(--muted);}
28235    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;}
28236    .filter-input{min-width:180px;cursor:text;}
28237    .table-wrap{width:100%;overflow-x:auto;}
28238    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28239    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;}
28240    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28241    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28242    #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;}
28243    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28244    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28245    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28246    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28247    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28248    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28249    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28250    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28251    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28252    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28253    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28254    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28255    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28256    tr:last-child td{border-bottom:none;}
28257    tr.selected td{background:var(--sel-bg);}
28258    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28259    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28260    tr{cursor:pointer;}
28261    tr.row-locked{opacity:.35;cursor:not-allowed;}
28262    tr.row-locked td{pointer-events:none;}
28263    .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;}
28264    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28265    .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;}
28266    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28267    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28268    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28269    .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);}
28270    .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);}
28271    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28272    .metric-num{font-weight:700;color:var(--text);}
28273    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28274    .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;}
28275    .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;}
28276    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28277    .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;}
28278    .btn:hover{background:var(--line);}
28279    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28280    .btn.primary:hover{opacity:.9;}
28281    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28282    .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;}
28283    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28284    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28285    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28286    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28287    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28288    .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;}
28289    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28290    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28291    .watched-chip-rm:hover{color:var(--oxide);}
28292    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28293    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28294    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28295    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28296    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28297    .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;}
28298    .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;}
28299    .btn-back:hover{background:var(--line);}
28300    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28301    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28302    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28303    .pagination-info{font-size:13px;color:var(--muted);}
28304    .pagination-btns{display:flex;gap:6px;}
28305    .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;}
28306    .pg-btn:hover:not(:disabled){background:var(--line);}
28307    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28308    .pg-btn:disabled{opacity:.35;cursor:default;}
28309    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28310    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28311    .site-footer a{color:var(--muted);}
28312    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28313    .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;}
28314    .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;}
28315    .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;}
28316    @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));}}
28317    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28318    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28319    .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);}
28320    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28321    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28322    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28323    .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);}
28324    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28325    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28326    .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;}
28327    .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;}
28328    .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%;}
28329    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28330    .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;}
28331    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28332    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28333    .hidden{display:none!important;}
28334    .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%;}
28335    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28336    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28337    .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;}
28338    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28339    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28340    .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;}
28341    .scope-option:hover{background:var(--line);}
28342    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28343    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28344    .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;}
28345    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28346    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28347    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28348    .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;}
28349  </style>
28350</head>
28351<body>
28352  <div class="background-watermarks" aria-hidden="true">
28353    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28354    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28355    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28356    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28357    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28358    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28359  </div>
28360  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28361  <div class="top-nav">
28362    <div class="top-nav-inner">
28363      <a class="brand" href="/">
28364        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28365        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28366      </a>
28367      <div class="nav-right">
28368        <a class="nav-pill" href="/">Home</a>
28369        <div class="nav-dropdown">
28370          <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>
28371          <div class="nav-dropdown-menu">
28372            <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>
28373          </div>
28374        </div>
28375        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28376        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28377        <div class="nav-dropdown">
28378          <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>
28379          <div class="nav-dropdown-menu">
28380            <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>
28381          </div>
28382        </div>
28383        <div class="server-status-wrap" id="server-status-wrap">
28384          <div class="nav-pill server-online-pill" id="server-status-pill">
28385            <span class="status-dot" id="status-dot"></span>
28386            <span id="server-status-label">Server</span>
28387            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28388          </div>
28389          <div class="server-status-tip">
28390            OxideSLOC is running — accessible on your network.
28391            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28392          </div>
28393        </div>
28394        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28395          <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>
28396        </button>
28397        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28398          <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>
28399          <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>
28400        </button>
28401      </div>
28402    </div>
28403  </div>
28404
28405  <div class="page">
28406    <div class="watched-bar">
28407      <div class="watched-bar-left">
28408        <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>
28409        <span class="watched-label">Watched Folders</span>
28410        <div class="watched-chips">
28411          {% if server_mode %}
28412          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28413          {% else %}
28414          {% for dir in watched_dirs %}
28415          <span class="watched-chip">
28416            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28417            <form method="POST" action="/watched-dirs/remove" style="display:contents">
28418              <input type="hidden" name="folder_path" value="{{ dir }}">
28419              <input type="hidden" name="redirect_to" value="/compare-scans">
28420              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
28421            </form>
28422          </span>
28423          {% endfor %}
28424          {% if watched_dirs.is_empty() %}
28425          <span class="watched-none">No folders watched — click Choose to add one</span>
28426          {% endif %}
28427          {% endif %}
28428        </div>
28429      </div>
28430      {% if !server_mode %}
28431      <div class="watched-bar-right">
28432        <button type="button" class="btn" id="add-watched-btn">
28433          <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>
28434          Choose
28435        </button>
28436        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28437          <input type="hidden" name="redirect_to" value="/compare-scans">
28438          <button type="submit" class="btn">&#8635; Refresh</button>
28439        </form>
28440      </div>
28441      {% endif %}
28442    </div>
28443    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28444      <div class="scan-overlay-card">
28445        <div class="scan-spinner"></div>
28446        <div class="scan-overlay-text">Scanning folder…</div>
28447        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28448      </div>
28449    </div>
28450    <style>
28451    .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);}
28452    .scan-overlay.active{display:flex;}
28453    .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;}
28454    .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;}
28455    @keyframes scanSpin{to{transform:rotate(360deg);}}
28456    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28457    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28458    </style>
28459    {% if total_scans > 0 %}
28460    <div class="summary-strip">
28461      <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>
28462      <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>
28463      <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>
28464      <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>
28465    </div>
28466    {% endif %}
28467    <section class="panel">
28468      <div class="panel-header">
28469        <div>
28470          <h1>Compare Scans</h1>
28471          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28472        </div>
28473        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28474          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28475            <button class="btn primary" id="compare-btn" disabled>
28476              <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>
28477              Compare <span class="sel-count" id="sel-count">0</span> Selected
28478            </button>
28479          </div>
28480        </div>
28481      </div>
28482
28483      {% if entries.is_empty() %}
28484      <div class="empty-state">
28485        <strong>No scans yet</strong>
28486        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.
28487      </div>
28488      {% else %}
28489      <div class="filter-row">
28490        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
28491        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28492        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
28493      </div>
28494      <div class="scope-panel hidden" id="scope-panel">
28495        <div class="scope-panel-label">
28496          <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>
28497          Compare scope — choose what to include
28498        </div>
28499        <div class="scope-options" id="scope-options"></div>
28500      </div>
28501      {% if total_scans > 0 %}
28502      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28503        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28504          <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>
28505          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28506        </div>
28507      </div>
28508      {% endif %}
28509      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28510        <span class="compare-all-label">
28511          <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>
28512          Quick Compare All
28513        </span>
28514      </div>
28515      <div class="table-wrap">
28516        <table id="compare-table">
28517          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28518          <thead>
28519            <tr id="compare-thead">
28520              <th><div class="col-resize-handle"></div></th>
28521              <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>
28522              <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>
28523              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28524              <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>
28525              <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>
28526              <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>
28527              <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>
28528              <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>
28529              <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>
28530              <th>Submodules<div class="col-resize-handle"></div></th>
28531            </tr>
28532          </thead>
28533          <tbody id="compare-tbody">
28534            {% for entry in entries %}
28535            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28536                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28537                data-project="{{ entry.project_label }}"
28538                data-files="{{ entry.files_analyzed }}"
28539                data-code="{{ entry.code_lines }}"
28540                data-comments="{{ entry.comment_lines }}"
28541                data-blank="{{ entry.blank_lines }}"
28542                data-branch="{{ entry.git_branch }}"
28543                data-commit="{{ entry.git_commit }}"
28544                data-submodules="{{ entry.submodule_names_csv }}">
28545              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28546              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28547              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28548              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28549              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28550              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28551              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28552              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28553              <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>
28554              <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>
28555              <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>
28556            </tr>
28557            {% endfor %}
28558          </tbody>
28559        </table>
28560      </div>
28561      <div class="pagination">
28562        <span class="pagination-info" id="pagination-info"></span>
28563        <div class="pagination-btns" id="pagination-btns"></div>
28564        <div class="flex-row">
28565          <span class="per-page-label">Show</span>
28566          <select class="per-page" id="per-page-sel">
28567            <option value="10">10 per page</option>
28568            <option value="25" selected>25 per page</option>
28569            <option value="50">50 per page</option>
28570            <option value="100">100 per page</option>
28571          </select>
28572          <span class="per-page-label" id="page-range-label"></span>
28573        </div>
28574      </div>
28575      {% endif %}
28576    </section>
28577  </div>
28578
28579  <footer class="site-footer">
28580    local code analysis - metrics, history and reports
28581    &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>
28582    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28583    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28584    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28585    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28586  </footer>
28587
28588  <script nonce="{{ csp_nonce }}">
28589    (function () {
28590      // ── Theme ──────────────────────────────────────────────────────────────
28591      var storageKey = 'oxide-sloc-theme';
28592      var body = document.body;
28593      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28594      var toggle = document.getElementById('theme-toggle');
28595      if (toggle) toggle.addEventListener('click', function () {
28596        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28597        body.classList.toggle('dark-theme', next === 'dark');
28598        try { localStorage.setItem(storageKey, next); } catch(e) {}
28599      });
28600
28601      // ── State ─────────────────────────────────────────────────────────────
28602      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28603      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28604      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28605      window._allCompareRows = allRows;
28606
28607      // ── Stat chips ────────────────────────────────────────────────────────
28608      (function() {
28609        var projects = {}, latestTs = '', latestRow = null;
28610        allRows.forEach(function(r) {
28611          var p = r.dataset.project || ''; if (p) projects[p] = true;
28612          var ts = r.dataset.timestamp || '';
28613          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28614        });
28615        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();}
28616        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>':'');}
28617        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28618        if (latestRow) {
28619          setChipVal('agg-code', latestRow.dataset.code);
28620          setChipVal('agg-files', latestRow.dataset.files);
28621        }
28622        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(); });
28623      })();
28624
28625      // ── Branch filter population ──────────────────────────────────────────
28626      (function() {
28627        var branches = {};
28628        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28629        var sel = document.getElementById('branch-filter');
28630        if (sel) Object.keys(branches).sort().forEach(function(b) {
28631          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28632        });
28633      })();
28634
28635      // ── Filter ────────────────────────────────────────────────────────────
28636      function getFilteredRows() {
28637        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28638        var branch = ((document.getElementById('branch-filter') || {}).value || '');
28639        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28640          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28641          if (branch && (r.dataset.branch || '') !== branch) return false;
28642          return true;
28643        });
28644      }
28645
28646      // ── Pagination ────────────────────────────────────────────────────────
28647      function renderPage() {
28648        var filtered = getFilteredRows();
28649        var total = filtered.length;
28650        var totalPages = Math.max(1, Math.ceil(total / perPage));
28651        currentPage = Math.min(currentPage, totalPages);
28652        var start = (currentPage - 1) * perPage;
28653        var end = Math.min(start + perPage, total);
28654        var shown = {};
28655        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28656        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28657          r.style.display = shown[r.dataset.run] ? '' : 'none';
28658        });
28659        var rl = document.getElementById('page-range-label');
28660        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28661        var info = document.getElementById('pagination-info');
28662        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28663        var btns = document.getElementById('pagination-btns');
28664        if (!btns) return;
28665        btns.innerHTML = '';
28666        function makeBtn(lbl, pg, active, disabled) {
28667          var b = document.createElement('button');
28668          b.className = 'pg-btn' + (active ? ' active' : '');
28669          b.textContent = lbl; b.disabled = disabled;
28670          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28671          return b;
28672        }
28673        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28674        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28675        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28676        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28677      }
28678
28679      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28680      window.applyFilters = function() { currentPage = 1; renderPage(); };
28681
28682      // ── Sorting ───────────────────────────────────────────────────────────
28683      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28684      function doSort(col, type, order) {
28685        var tbody = document.getElementById('compare-tbody');
28686        if (!tbody) return;
28687        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28688        rows.sort(function(a, b) {
28689          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28690          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28691          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28692          return va < vb ? 1 : va > vb ? -1 : 0;
28693        });
28694        rows.forEach(function(r) { tbody.appendChild(r); });
28695        currentPage = 1; renderPage();
28696      }
28697      sortHeaders.forEach(function(th) {
28698        th.addEventListener('click', function(e) {
28699          if (e.target.classList.contains('col-resize-handle')) return;
28700          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28701          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28702          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28703          th.classList.add('sort-' + sortOrder);
28704          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28705          doSort(col, type, sortOrder);
28706        });
28707      });
28708
28709      // Apply default sort (timestamp desc) on initial load
28710      (function() {
28711        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28712        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28713      })();
28714
28715      // ── Column resize ─────────────────────────────────────────────────────
28716      (function() {
28717        var table = document.getElementById('compare-table');
28718        if (!table) return;
28719        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28720        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28721        ths.forEach(function(th, i) {
28722          var handle = th.querySelector('.col-resize-handle');
28723          if (!handle || !cols[i]) return;
28724          var startX, startW;
28725          handle.addEventListener('mousedown', function(e) {
28726            e.stopPropagation(); e.preventDefault();
28727            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28728            handle.classList.add('dragging');
28729            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28730            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28731            document.addEventListener('mousemove', onMove);
28732            document.addEventListener('mouseup', onUp);
28733          });
28734        });
28735      })();
28736
28737      // ── Full-commit hover tooltip ─────────────────────────────────────────
28738      // The commit chips live inside an overflow:auto table wrapper, which would
28739      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28740      // (escaping the scroll container) and follow the cursor. Event delegation
28741      // keeps it working after pagination/sorting re-renders the rows.
28742      (function() {
28743        var tip = document.createElement('div');
28744        tip.className = 'commit-tip';
28745        tip.setAttribute('role', 'tooltip');
28746        document.body.appendChild(tip);
28747        var shown = false;
28748        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28749        function place(e) {
28750          var pad = 14, r = tip.getBoundingClientRect();
28751          var x = e.clientX + pad, y = e.clientY + pad;
28752          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28753          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28754          tip.style.left = x + 'px'; tip.style.top = y + 'px';
28755        }
28756        function hide() { tip.style.display = 'none'; shown = false; }
28757        document.addEventListener('mouseover', function(e) {
28758          var chip = chipFrom(e.target);
28759          if (!chip) return;
28760          var full = chip.getAttribute('data-full-commit');
28761          if (!full) return;
28762          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28763        });
28764        document.addEventListener('mousemove', function(e) {
28765          if (!shown) return;
28766          if (chipFrom(e.target)) place(e); else hide();
28767        });
28768        document.addEventListener('mouseout', function(e) {
28769          if (chipFrom(e.target)) hide();
28770        });
28771      })();
28772
28773      // ── Reset view ────────────────────────────────────────────────────────
28774      window.resetView = function() {
28775        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28776        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28777        sortCol = null; sortOrder = 'asc';
28778        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28779        var tbody = document.getElementById('compare-tbody');
28780        if (tbody) {
28781          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28782          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28783          rows.forEach(function(r) { tbody.appendChild(r); });
28784        }
28785        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28786        var table = document.getElementById('compare-table');
28787        currentPage = 1; renderPage();
28788        currentPage = 1; renderPage();
28789      };
28790
28791      renderPage();
28792      buildCompareAllBar();
28793
28794      // ── Row selection state ───────────────────────────────────────────────
28795      var selected = [];
28796      var lockedProject = null; // project label of first selected scan
28797
28798      function updateCompareBtn() {
28799        var btn = document.getElementById('compare-btn');
28800        var cnt = document.getElementById('sel-count');
28801        if (!btn) return;
28802        btn.disabled = selected.length < 2;
28803        if (cnt) cnt.textContent = selected.length;
28804      }
28805
28806      function applyProjectLock() {
28807        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28808        allRows.forEach(function(r) {
28809          if (lockedProject === null) {
28810            r.classList.remove('row-locked');
28811          } else {
28812            var proj = r.dataset.project || '';
28813            if (proj !== lockedProject) {
28814              r.classList.add('row-locked');
28815            } else {
28816              r.classList.remove('row-locked');
28817            }
28818          }
28819        });
28820      }
28821
28822      function toggleRow(row) {
28823        if (row.classList.contains('row-locked')) return;
28824        var vid = row.dataset.vid || row.dataset.run;
28825        var idx = selected.indexOf(vid);
28826        if (idx >= 0) {
28827          selected.splice(idx, 1);
28828          row.classList.remove('selected');
28829          var b = document.getElementById('badge-' + vid);
28830          if (b) b.textContent = '';
28831          // Release project lock if nothing selected
28832          if (selected.length === 0) lockedProject = null;
28833        } else {
28834          // Set project lock on first selection
28835          if (selected.length === 0) lockedProject = row.dataset.project || null;
28836          selected.push(vid);
28837          row.classList.add('selected');
28838        }
28839        selected.forEach(function(v, i) {
28840          var b = document.getElementById('badge-' + v);
28841          if (b) b.textContent = i + 1;
28842        });
28843        applyProjectLock();
28844        updateCompareBtn();
28845        buildScopePanel();
28846      }
28847
28848      // ── Compare-All bar ───────────────────────────────────────────────────
28849      function buildCompareAllBar() {
28850        var bar = document.getElementById('compare-all-bar');
28851        if (!bar) return;
28852        // Group all rows by project label.
28853        var groups = {};
28854        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28855        // Use all rows from the source data (not just visible).
28856        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28857        // We need ALL rows across all pages, not just the rendered ones.
28858        // Use the underlying allRows array that the pagination JS also uses.
28859        var sourceRows = window._allCompareRows || allRowsAll;
28860        sourceRows.forEach(function(r) {
28861          var proj = r.dataset.project || '';
28862          var vid = r.dataset.vid || r.dataset.run || '';
28863          if (!proj || !vid) return;
28864          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28865          groups[proj].ids.push(vid);
28866          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28867        });
28868        // Build buttons for each project with >= 2 scans.
28869        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28870        if (!keys.length) { bar.style.display = 'none'; return; }
28871        bar.style.display = 'flex';
28872        // Remove old buttons (keep label).
28873        var oldBtns = bar.querySelectorAll('.compare-all-btn');
28874        oldBtns.forEach(function(b) { b.remove(); });
28875        keys.sort();
28876        keys.forEach(function(proj) {
28877          var g = groups[proj];
28878          var btn = document.createElement('button');
28879          btn.className = 'compare-all-btn';
28880          btn.type = 'button';
28881          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28882          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28883          btn.addEventListener('click', function() {
28884            // Sort ids by timestamp (ascending).
28885            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28886            pairs.sort(function(a, b) { return a.ts - b.ts; });
28887            var sorted = pairs.map(function(p) { return p.id; });
28888            if (sorted.length === 2) {
28889              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28890            } else {
28891              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28892            }
28893          });
28894          bar.appendChild(btn);
28895        });
28896      }
28897
28898      // ── Scope panel ───────────────────────────────────────────────────────
28899      var selectedScope = 'all';
28900
28901      function buildScopePanel() {
28902        var panel = document.getElementById('scope-panel');
28903        var opts = document.getElementById('scope-options');
28904        if (!panel || !opts) return;
28905        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28906
28907        // Collect union of submodules from all selected rows.
28908        var allSubs = {};
28909        selected.forEach(function(vid) {
28910          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28911          if (!row) return;
28912          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28913        });
28914        var subList = Object.keys(allSubs).sort();
28915        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28916
28917        panel.classList.remove('hidden');
28918        opts.innerHTML = '';
28919
28920        function makeOption(value, label, title) {
28921          var div = document.createElement('div');
28922          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28923          div.dataset.scopeValue = value;
28924          if (title) div.title = title;
28925          var radio = document.createElement('span');
28926          radio.className = 'scope-option-radio';
28927          var lbl = document.createElement('span');
28928          lbl.textContent = label;
28929          div.appendChild(radio);
28930          div.appendChild(lbl);
28931          div.addEventListener('click', function() {
28932            selectedScope = value;
28933            opts.querySelectorAll('.scope-option').forEach(function(o) {
28934              o.classList.toggle('selected', o.dataset.scopeValue === value);
28935            });
28936          });
28937          return div;
28938        }
28939
28940        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28941        var sep = document.createElement('span');
28942        sep.className = 'scope-option-sep';
28943        opts.appendChild(sep);
28944        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28945        subList.forEach(function(s) {
28946          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28947        });
28948      }
28949
28950      function doCompare() {
28951        if (selected.length < 2) return;
28952        if (selected.length === 2) {
28953          // Two-scan delta (existing flow with scope support).
28954          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28955          if (selectedScope === 'super') url += '&scope=super';
28956          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28957          window.location.href = url;
28958        } else {
28959          // Multi-scan timeline (N >= 3) — pass scope params too.
28960          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28961          if (selectedScope === 'super') url += '&scope=super';
28962          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28963          window.location.href = url;
28964        }
28965      }
28966
28967      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28968      var cbtn = document.getElementById('compare-btn');
28969      if (cbtn) cbtn.addEventListener('click', doCompare);
28970      var pfEl = document.getElementById('project-filter');
28971      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28972      var bfEl = document.getElementById('branch-filter');
28973      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28974      var rvBtn = document.getElementById('reset-view-btn');
28975      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28976      var ppSel = document.getElementById('per-page-sel');
28977      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28978
28979      var cmpTbody = document.getElementById('compare-tbody');
28980      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28981        var row = e.target.closest('.compare-row');
28982        if (row) toggleRow(row);
28983      });
28984
28985      (function randomizeWatermarks() {
28986        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28987        if (!wms.length) return;
28988        var placed = [];
28989        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;}
28990        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];}
28991        var half=Math.floor(wms.length/2);
28992        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;});
28993      })();
28994
28995      (function spawnCodeParticles() {
28996        var container = document.getElementById('code-particles');
28997        if (!container) return;
28998        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'];
28999        for (var i = 0; i < 38; i++) {
29000          (function(idx) {
29001            var el = document.createElement('span');
29002            el.className = 'code-particle';
29003            el.textContent = snippets[idx % snippets.length];
29004            var left = Math.random() * 94 + 2;
29005            var top = Math.random() * 88 + 6;
29006            var dur = (Math.random() * 10 + 9).toFixed(1);
29007            var delay = (Math.random() * 18).toFixed(1);
29008            var rot = (Math.random() * 26 - 13).toFixed(1);
29009            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29010            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';
29011            container.appendChild(el);
29012          })(i);
29013        }
29014      })();
29015
29016      // ── Watched folder picker ─────────────────────────────────────────────
29017      (function(){
29018        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');};
29019        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);
29020      })();
29021      (function() {
29022        var btn = document.getElementById('add-watched-btn');
29023        if (!btn) return;
29024        btn.addEventListener('click', function() {
29025          fetch('/pick-directory?kind=reports')
29026            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29027            .then(function(data) {
29028              if (!data.cancelled && data.selected_path) {
29029                var form = document.createElement('form');
29030                form.method = 'POST';
29031                form.action = '/watched-dirs/add';
29032                var ri = document.createElement('input');
29033                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29034                var fi = document.createElement('input');
29035                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29036                form.appendChild(ri); form.appendChild(fi);
29037                document.body.appendChild(form);
29038                if (window.__scanOverlay) window.__scanOverlay();
29039                form.submit();
29040              }
29041            })
29042            .catch(function(e) { alert('Could not open folder picker: ' + e); });
29043        });
29044      })();
29045
29046      // ── Submodule chip truncation ─────────────────────────────────────────
29047      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29048        var chips = cell.querySelectorAll('.submod-chip');
29049        var MAX = 4;
29050        if (chips.length <= MAX) return;
29051        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29052        var badge = document.createElement('span');
29053        badge.className = 'submod-overflow-badge';
29054        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29055        badge.textContent = '+' + (chips.length - MAX) + ' more';
29056        cell.appendChild(badge);
29057        cell.style.maxHeight = 'none';
29058      });
29059    })();
29060  </script>
29061  <script nonce="{{ csp_nonce }}">
29062  (function(){
29063    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'}];
29064    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);});}
29065    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29066    function init(){
29067      var btn=document.getElementById('settings-btn');if(!btn)return;
29068      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29069      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>';
29070      document.body.appendChild(m);
29071      var g=document.getElementById('scheme-grid');
29072      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);});
29073      var cl=document.getElementById('settings-close');
29074      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);});})();
29075      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');});
29076      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29077      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29078    }
29079    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29080  }());
29081  </script>
29082  <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]';
29083  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;}
29084  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>
29085</body>
29086</html>
29087"##,
29088    ext = "html"
29089)]
29090struct CompareSelectTemplate {
29091    version: &'static str,
29092    entries: Vec<HistoryEntryRow>,
29093    total_scans: usize,
29094    watched_dirs: Vec<String>,
29095    csp_nonce: String,
29096    server_mode: bool,
29097}
29098
29099// ── CompareTemplate ────────────────────────────────────────────────────────────
29100
29101#[derive(Template)]
29102#[template(
29103    source = r##"
29104<!doctype html>
29105<html lang="en">
29106<head>
29107  <meta charset="utf-8">
29108  <meta name="viewport" content="width=device-width, initial-scale=1">
29109  <title>OxideSLOC | Scan Delta</title>
29110  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29111  <style nonce="{{ csp_nonce }}">
29112    :root {
29113      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29114      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29115      --nav:#283790; --nav-2:#013e6b;
29116      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29117      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29118      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29119    }
29120    body.dark-theme {
29121      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29122      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29123    }
29124    *{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;}
29125    .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);}
29126    .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;}
29127    .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));}
29128    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29129    .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;}
29130    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29131    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29132    @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; } }
29133    .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;}
29134    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29135    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29136    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29137    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29138    .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;}
29139    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29140    .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);}
29141    .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;}
29142    .settings-close:hover{color:var(--text);background:var(--surface-2);}
29143    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29144    .settings-modal-body{padding:14px 16px 16px;}
29145    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29146    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29147    .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;}
29148    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29149    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29150    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29151    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29152    .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;}
29153    .tz-select:focus{border-color:var(--oxide);}
29154    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29155    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29156    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29157    .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;}
29158    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29159    .hero-body{display:block;}
29160    .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;}
29161    .btn-back:hover{background:var(--line);}
29162    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29163    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29164    .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;}
29165    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29166    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;}
29167    .muted{color:var(--muted);font-size:14px;}
29168    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29169    .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;}
29170    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29171    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29172    .vpill-arrow{font-size:20px;color:var(--muted);}
29173    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29174    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29175    .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;}
29176    .delta-card.delta-card-wide{padding:22px 24px;}
29177    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29178    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29179    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29180    .delta-card-from{font-size:15px;color:var(--muted);}
29181    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29182    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29183    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29184    .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%;}
29185    .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;}
29186    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29187    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29188    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29189    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29190    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29191    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29192    .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;}
29193    .meta-card-commit:hover{color:var(--oxide);}
29194    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29195    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29196    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29197    .meta-value{color:var(--text);font-size:13px;}
29198    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29199    .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;}
29200    .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);}
29201    .delta-card:hover .dc-tip{display:block;}
29202    .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;}
29203    .export-btn:hover{background:var(--line);}
29204    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29205    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29206    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29207    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29208    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29209    .delta-card-change.zero{color:var(--muted);background:transparent;}
29210    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29211    .delta-card-pct.pos{color:var(--pos);}
29212    .delta-card-pct.neg{color:var(--neg);}
29213    .delta-card-pct.zero{color:var(--muted);}
29214    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29215    .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;}
29216    .insight-card.insight-flag{border-color:var(--oxide);}
29217    .insight-card:hover .dc-tip{display:block;}
29218    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29219    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29220    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29221    .insight-label.flag{color:var(--oxide);}
29222    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29223    .insight-val.pos{color:var(--pos);}
29224    .insight-val.neg{color:var(--neg);}
29225    .insight-val.high{color:#c0392a;}
29226    .insight-val.med{color:#926000;}
29227    .insight-val.low{color:var(--pos);}
29228    body.dark-theme .insight-val.high{color:#ff6b6b;}
29229    body.dark-theme .insight-val.med{color:#f0c060;}
29230    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29231    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29232    .fc-row{display:flex;align-items:center;gap:8px;}
29233    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29234    .fc-label{color:var(--muted);}
29235    .fc-modified .fc-count{color:#926000;}
29236    .fc-added .fc-count{color:var(--pos);}
29237    .fc-removed .fc-count{color:var(--neg);}
29238    .fc-unchanged .fc-count{color:var(--muted);}
29239    .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29240    .fc-total .fc-count{color:var(--text);}
29241    .fc-total .fc-label{font-weight:700;}
29242    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29243    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29244    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29245    .chip.modified{background:#fff2d8;color:#926000;}
29246    .chip.added{background:#e8f5ed;color:#1a8f47;}
29247    .chip.removed{background:#fdeaea;color:#b33b3b;}
29248    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29249    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29250    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29251    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29252    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29253    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29254    .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;}
29255    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29256    .tab-btn:hover:not(.active){background:var(--line);}
29257    .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;}
29258    .btn-reset:hover{background:var(--line);}
29259    .table-wrap{width:100%;overflow-x:auto;}
29260    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29261    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);}
29262    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29263    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29264    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29265    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29266    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29267    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29268    tr:last-child td{border-bottom:none;}
29269    tr:hover td{background:var(--surface-2);}
29270    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29271    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29272    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29273    /* Fixed layout: column widths come from the colgroup, not from scanning every
29274       row. With auto layout a large file matrix forces the browser to re-measure
29275       all cells on each reflow, which freezes the page during sort/resize. */
29276    #delta-table{table-layout:fixed;}
29277    #delta-table col:nth-child(1){width:32%;}
29278    #delta-table col:nth-child(2){width:11%;}
29279    #delta-table col:nth-child(3){width:11%;}
29280    #delta-table col:nth-child(4){width:16%;}
29281    #delta-table col:nth-child(5){width:10%;}
29282    #delta-table col:nth-child(6){width:10%;}
29283    #delta-table col:nth-child(7){width:10%;}
29284    tr.row-added td{background:rgba(26,143,71,0.04);}
29285    tr.row-removed td{background:rgba(179,59,59,0.06);}
29286    tr.row-modified td{background:rgba(146,96,0,0.04);}
29287    tr.row-unchanged td{color:var(--muted);}
29288    tr.row-unchanged .status-badge{opacity:.65;}
29289    .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;}
29290    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29291    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29292    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29293    .status-badge.modified{background:#fff2d8;color:#926000;}
29294    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29295    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29296    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29297    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29298    .delta-val{font-weight:700;}
29299    .delta-val.pos{color:var(--pos);}
29300    .delta-val.neg{color:var(--neg);}
29301    .delta-val.zero{color:var(--muted);}
29302    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29303    .from-to strong{color:var(--text);font-weight:700;}
29304    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29305    .from-to .ft-absent{color:var(--muted);font-weight:600;}
29306    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29307    .site-footer a{color:var(--muted);}
29308    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;}
29309    body.pdf-mode{background:#fff!important;}
29310    body.pdf-mode .page{padding:4px 6px 4px!important;}
29311    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29312    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29313    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29314    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29315    .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;}
29316    .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;}
29317    .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;}
29318    @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));}}
29319    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29320    .path-link:hover{color:var(--oxide-2);}
29321    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29322    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29323    a.vpill-id:hover{color:var(--oxide);}
29324    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29325    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29326    .pagination-info{font-size:13px;color:var(--muted);}
29327    .pagination-btns{display:flex;gap:6px;}
29328    .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;}
29329    .pg-btn:hover:not(:disabled){background:var(--line);}
29330    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29331    .pg-btn:disabled{opacity:.35;cursor:default;}
29332    .per-page-label{font-size:13px;color:var(--muted);}
29333    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;}
29334    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29335    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29336    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29337    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29338    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29339    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29340    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29341    .tab-btn.tab-unchanged{color:var(--muted);}
29342    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29343    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29344    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29345    .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;}
29346    .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;}
29347    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29348    .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;}
29349    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29350    .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;}
29351    .submod-scope-btn:hover{background:var(--line);}
29352    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29353    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29354    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29355    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29356    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29357    body.dark-theme .ic-card{background:var(--surface-2);}
29358    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29359    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29360    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29361    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29362    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29363    .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);}
29364    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29365    .ic-card-h2-row .ic-card-h2{margin:0;}
29366    .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;}
29367    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29368    .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;}
29369    .ic-svg-modal-ov.open{display:flex;}
29370    .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);}
29371    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29372    .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);}
29373    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29374    .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;}
29375    .ic-svg-modal-close:hover{background:var(--line);}
29376    .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;}
29377    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29378    .chart-metric-btn:hover:not(.active){background:var(--line);}
29379    .chart-wrap{width:100%;overflow-x:auto;}
29380    #cmp-tl-svg{display:block;width:100%;}
29381    .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);}
29382    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29383    #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;}
29384  </style>
29385</head>
29386<body>
29387  {{ loading_overlay|safe }}
29388  <div class="background-watermarks" aria-hidden="true">
29389    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29390    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29391    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29392    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29393    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29394    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29395  </div>
29396  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29397  <div class="top-nav">
29398    <div class="top-nav-inner">
29399      <a class="brand" href="/">
29400        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29401        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29402      </a>
29403      <div class="nav-right">
29404        <a class="nav-pill" href="/">Home</a>
29405        <div class="nav-dropdown">
29406          <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>
29407          <div class="nav-dropdown-menu">
29408            <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>
29409          </div>
29410        </div>
29411        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29412        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29413        <div class="nav-dropdown">
29414          <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>
29415          <div class="nav-dropdown-menu">
29416            <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>
29417          </div>
29418        </div>
29419        <div class="server-status-wrap" id="server-status-wrap">
29420          <div class="nav-pill server-online-pill" id="server-status-pill">
29421            <span class="status-dot" id="status-dot"></span>
29422            <span id="server-status-label">Server</span>
29423            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29424          </div>
29425          <div class="server-status-tip">
29426            OxideSLOC is running — accessible on your network.
29427            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29428          </div>
29429        </div>
29430        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29431          <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>
29432        </button>
29433        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29434          <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>
29435          <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>
29436        </button>
29437      </div>
29438    </div>
29439  </div>
29440
29441  <div class="page">
29442    <section class="hero">
29443      <div class="hero-header">
29444        <div>
29445          <h1 class="delta-title">Scan Delta</h1>
29446          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29447          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29448            {% if let Some(sub) = active_submodule %}
29449            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29450            {% else if super_scope_active %}
29451            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29452            {% else %}
29453            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29454            {% endif %}
29455            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29456          </div>
29457        </div>
29458        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29459          <a class="btn-back" href="/compare-scans">
29460            <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>
29461            Compare Scans
29462          </a>
29463          <div class="export-group" style="margin-top:12px;">
29464            <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>
29465            <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>
29466          </div>
29467        </div>
29468      </div>
29469      {% if has_any_submodule_data %}
29470      <div class="submod-scope-bar">
29471        <span class="submod-scope-label">
29472          <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>
29473          Scope:
29474        </span>
29475        <div class="submod-scope-divider"></div>
29476        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29477           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
29478           title="All files — super-repo and all submodules combined">Full scan</a>
29479        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29480           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
29481           title="Only files that are not part of any submodule">Super-repo only</a>
29482        {% for sub in submodule_options %}
29483        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29484           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
29485           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29486        {% endfor %}
29487      </div>
29488      {% endif %}
29489      <div class="hero-body">
29490      <div class="meta-strip">
29491        <div class="delta-card delta-card-meta">
29492          <div class="meta-card-header">
29493            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29494            <div class="meta-card-project-col">
29495              <div class="meta-card-project">{{ project_name }}</div>
29496              {% if has_any_submodule_data %}
29497              {% if let Some(sub) = active_submodule %}
29498              <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>
29499              {% else if super_scope_active %}
29500              <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>
29501              {% else %}
29502              <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>
29503              {% endif %}
29504              {% endif %}
29505            </div>
29506          </div>
29507          {% if !baseline_git_commit.is_empty() %}
29508          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29509          {% else %}
29510          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29511          {% endif %}
29512          <div class="meta-card-rows">
29513            <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>
29514            <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>
29515            <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>
29516            <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>
29517            {% if let Some(tags) = baseline_git_tags %}
29518            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29519            {% endif %}
29520          </div>
29521        </div>
29522        <div class="delta-card delta-card-meta">
29523          <div class="meta-card-header">
29524            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29525            <div class="meta-card-project-col">
29526              <div class="meta-card-project">{{ project_name }}</div>
29527              {% if has_any_submodule_data %}
29528              {% if let Some(sub) = active_submodule %}
29529              <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>
29530              {% else if super_scope_active %}
29531              <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>
29532              {% else %}
29533              <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>
29534              {% endif %}
29535              {% endif %}
29536            </div>
29537          </div>
29538          {% if !current_git_commit.is_empty() %}
29539          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29540          {% else %}
29541          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29542          {% endif %}
29543          <div class="meta-card-rows">
29544            <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>
29545            <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>
29546            <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>
29547            <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>
29548            {% if let Some(tags) = current_git_tags %}
29549            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29550            {% endif %}
29551          </div>
29552        </div>
29553      </div>
29554      <div class="delta-strip">
29555        <div class="delta-card">
29556          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29557          <div class="delta-card-label">Code lines</div>
29558          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29559          <div class="delta-card-to">{{ current_code_fmt }}</div>
29560          {% 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>
29561          {% 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>
29562          {% else %}<div class="delta-card-pct zero">±0%</div>
29563          {% endif %}
29564        </div>
29565        <div class="delta-card">
29566          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29567          <div class="delta-card-label">Files analyzed</div>
29568          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29569          <div class="delta-card-to">{{ current_files_fmt }}</div>
29570          {% 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>
29571          {% 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>
29572          {% else %}<div class="delta-card-pct zero">±0%</div>
29573          {% endif %}
29574        </div>
29575        <div class="delta-card">
29576          <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>
29577          <div class="delta-card-label">Comment lines</div>
29578          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29579          <div class="delta-card-to">{{ current_comments_fmt }}</div>
29580          {% 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>
29581          {% 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>
29582          {% else %}<div class="delta-card-pct zero">±0%</div>
29583          {% endif %}
29584        </div>
29585        {{ coverage_delta_card|safe }}
29586        <div class="delta-card delta-card-wide">
29587          <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>
29588          <div class="delta-card-label">File changes</div>
29589          <div class="file-changes-grid">
29590            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29591            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29592            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29593            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29594            <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>
29595          </div>
29596        </div>
29597      </div>
29598      <div class="insights-panel">
29599        <div class="insight-card">
29600          <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>
29601          <div class="insight-label">Lines Added</div>
29602          <div class="insight-val pos">+{{ code_lines_added }}</div>
29603          <div class="insight-sub">New or grown source lines</div>
29604        </div>
29605        <div class="insight-card">
29606          <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>
29607          <div class="insight-label">Lines Removed</div>
29608          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
29609          <div class="insight-sub">Deleted or shrunk source lines</div>
29610        </div>
29611        <div class="insight-card">
29612          <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>
29613          <div class="insight-label">Lines Modified</div>
29614          <div class="insight-val">{{ code_lines_modified }}</div>
29615          <div class="insight-sub">Code lines in modified files</div>
29616        </div>
29617        <div class="insight-card">
29618          <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>
29619          <div class="insight-label">Lines Unmodified</div>
29620          <div class="insight-val">{{ code_lines_unmodified }}</div>
29621          <div class="insight-sub">Code lines in unchanged files</div>
29622        </div>
29623        <div class="insight-card">
29624          <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29625          <div class="insight-label">Lines Total</div>
29626          <div class="insight-val">{{ code_lines_total }}</div>
29627          <div class="insight-sub">Added + removed + modified + unmodified</div>
29628        </div>
29629        <div class="insight-card">
29630          <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>
29631          <div class="insight-label">Churn Rate</div>
29632          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29633          <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>
29634        </div>
29635        {% if scope_flag %}
29636        <div class="insight-card insight-flag">
29637          <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>
29638          <div class="insight-label flag">Scope Signal</div>
29639          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29640          <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>
29641        </div>
29642        {% endif %}
29643      </div>
29644      </div>
29645    </section>
29646
29647    <section class="panel" id="inline-charts-section">
29648      <div class="panel-title">Scan Delta Charts</div>
29649      <div class="ic-grid">
29650        <div class="ic-card" style="grid-column:span 2">
29651          <div class="ic-card-h2-row">
29652            <span class="ic-card-h2">Timeline</span>
29653            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29654              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29655              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29656              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29657              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29658              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29659            </div>
29660            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
29661          </div>
29662          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29663        </div>
29664        <div class="ic-card">
29665          <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>
29666          <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>
29667          <div id="ic-c1"></div>
29668        </div>
29669        <div class="ic-card" id="ic-lang-card">
29670          <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>
29671          <div id="ic-c3"></div>
29672        </div>
29673        <div class="ic-card">
29674          <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>
29675          <div id="ic-c2"></div>
29676        </div>
29677        <div class="ic-card">
29678          <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>
29679          <div id="ic-c4"></div>
29680        </div>
29681      </div>
29682      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29683        <div class="ic-svg-modal">
29684          <div class="ic-svg-modal-hdr">
29685            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29686            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
29687          </div>
29688          <div id="ic-svg-modal-body"></div>
29689        </div>
29690      </div>
29691    </section>
29692
29693    <section class="panel">
29694      <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>
29695      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29696        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29697          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29698          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29699          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29700          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29701          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29702        </div>
29703        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29704          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
29705          <div class="export-group">
29706            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
29707            <button type="button" class="export-btn" id="delta-csv-btn">
29708              <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>
29709              CSV
29710            </button>
29711            <button type="button" class="export-btn" id="delta-xls-btn">
29712              <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>
29713              Excel
29714            </button>
29715          </div>
29716        </div>
29717      </div>
29718
29719      <div class="table-wrap">
29720      <table id="delta-table">
29721        <colgroup>
29722          <col>
29723          <col>
29724          <col>
29725          <col>
29726          <col>
29727          <col>
29728          <col>
29729        </colgroup>
29730        <thead>
29731          <tr id="delta-thead">
29732            <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>
29733            <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>
29734            <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>
29735            <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>
29736            <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>
29737            <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>
29738            <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>
29739          </tr>
29740        </thead>
29741        <tbody id="delta-tbody">
29742          {% for row in file_rows %}
29743          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29744              data-path="{{ row.relative_path }}"
29745              data-language="{{ row.language }}"
29746              data-baseline-code="{{ row.baseline_code }}"
29747              data-current-code="{{ row.current_code }}"
29748              data-code-delta="{{ row.code_delta_str }}"
29749              data-comment-delta="{{ row.comment_delta_str }}"
29750              data-total-delta="{{ row.total_delta_str }}"
29751              data-orig-idx="">
29752            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29753            <td class="hide-sm">{{ row.language }}</td>
29754            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29755            <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>
29756            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29757            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29758            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29759          </tr>
29760          {% endfor %}
29761        </tbody>
29762      </table>
29763      </div>
29764      <div class="pagination">
29765        <span class="pagination-info" id="pg-range-label"></span>
29766        <div class="pagination-btns" id="pg-btns"></div>
29767        <div class="flex-row">
29768          <span class="per-page-label">Show</span>
29769          <select class="per-page" id="per-page-sel">
29770            <option value="10">10 per page</option>
29771            <option value="25" selected>25 per page</option>
29772            <option value="50">50 per page</option>
29773            <option value="100">100 per page</option>
29774          </select>
29775        </div>
29776      </div>
29777    </section>
29778  </div>
29779
29780  <div id="ic-tt"></div>
29781
29782  <footer class="site-footer">
29783    local code analysis - metrics, history and reports
29784    &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>
29785    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29786    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29787    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29788    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
29789  </footer>
29790
29791  <script nonce="{{ csp_nonce }}">
29792    (function () {
29793      var storageKey = 'oxide-sloc-theme';
29794      var body = document.body;
29795      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29796      var toggle = document.getElementById('theme-toggle');
29797      if (toggle) toggle.addEventListener('click', function () {
29798        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29799        body.classList.toggle('dark-theme', next === 'dark');
29800        try { localStorage.setItem(storageKey, next); } catch(e) {}
29801      });
29802
29803      (function randomizeWatermarks() {
29804        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29805        if (!wms.length) return;
29806        var placed = [];
29807        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;}
29808        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];}
29809        var half=Math.floor(wms.length/2);
29810        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;});
29811      })();
29812
29813      (function spawnCodeParticles() {
29814        var container = document.getElementById('code-particles');
29815        if (!container) return;
29816        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'];
29817        for (var i = 0; i < 38; i++) {
29818          (function(idx) {
29819            var el = document.createElement('span');
29820            el.className = 'code-particle';
29821            el.textContent = snippets[idx % snippets.length];
29822            var left = Math.random() * 94 + 2;
29823            var top = Math.random() * 88 + 6;
29824            var dur = (Math.random() * 10 + 9).toFixed(1);
29825            var delay = (Math.random() * 18).toFixed(1);
29826            var rot = (Math.random() * 26 - 13).toFixed(1);
29827            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29828            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';
29829            container.appendChild(el);
29830          })(i);
29831        }
29832      })();
29833    })();
29834
29835    var activeStatusFilter = 'all';
29836    var deltaPerPage = 25, deltaCurrPage = 1;
29837
29838    function openFolder(path) {
29839      fetch('/open-path?path=' + encodeURIComponent(path))
29840        .then(function (r) { return r.json(); })
29841        .then(function (d) {
29842          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29843        })
29844        .catch(function () {});
29845    }
29846
29847    // \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
29848    // The server renders every row once; we lift them into a plain-data array and
29849    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29850    // filtering run on the array (no DOM churn) and each render rebuilds just one
29851    // page (~25 rows). This keeps every interaction O(page) instead of O(all
29852    // files): a 28k-row table previously re-touched every node on each click
29853    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29854    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29855
29856    function parseDeltaNum(str) {
29857      if (!str || str === '\u2014') return 0;
29858      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29859    }
29860
29861    function captureDelta() {
29862      var tbody = document.getElementById('delta-tbody');
29863      if (!tbody) return;
29864      var rows = tbody.querySelectorAll('.delta-row');
29865      for (var i = 0; i < rows.length; i++) {
29866        var r = rows[i];
29867        DELTA.push({
29868          h: r.innerHTML,
29869          cls: r.className,
29870          path: r.getAttribute('data-path') || '',
29871          lang: r.getAttribute('data-language') || '',
29872          status: r.getAttribute('data-status') || '',
29873          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29874          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29875          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29876          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29877          td: parseDeltaNum(r.getAttribute('data-total-delta')),
29878          bcs: r.getAttribute('data-baseline-code') || '',
29879          ccs: r.getAttribute('data-current-code') || '',
29880          cds: r.getAttribute('data-code-delta') || '',
29881          cmds: r.getAttribute('data-comment-delta') || '',
29882          tds: r.getAttribute('data-total-delta') || ''
29883        });
29884      }
29885      tbody.innerHTML = '';
29886    }
29887
29888    function applyDeltaQuery() {
29889      var v = (activeStatusFilter === 'all') ? DELTA.slice()
29890        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29891      if (sortCol) {
29892        var asc = sortOrder === 'asc';
29893        v.sort(function(a, b) {
29894          var va, vb;
29895          if (sortCol === 'path') { va = a.path; vb = b.path; }
29896          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29897          else if (sortCol === 'status') { va = a.status; vb = b.status; }
29898          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29899          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29900          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29901          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29902          else { return 0; }
29903          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29904          return va < vb ? 1 : va > vb ? -1 : 0;
29905        });
29906      }
29907      _deltaView = v;
29908      deltaCurrPage = 1;
29909      renderDeltaPage();
29910    }
29911
29912    function renderDeltaPage() {
29913      var total = _deltaView.length;
29914      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29915      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29916      if (deltaCurrPage < 1) deltaCurrPage = 1;
29917      var start = (deltaCurrPage - 1) * deltaPerPage;
29918      var end = Math.min(start + deltaPerPage, total);
29919      var tbody = document.getElementById('delta-tbody');
29920      if (tbody) {
29921        var html = '';
29922        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29923        tbody.innerHTML = html;
29924      }
29925      var rl = document.getElementById('pg-range-label');
29926      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29927      var btns = document.getElementById('pg-btns');
29928      if (!btns) return;
29929      btns.innerHTML = '';
29930      if (totalPages <= 1) return;
29931      function makeBtn(lbl, pg, active, disabled) {
29932        var b = document.createElement('button');
29933        b.className = 'pg-btn' + (active ? ' active' : '');
29934        b.textContent = lbl; b.disabled = disabled;
29935        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29936        return b;
29937      }
29938      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29939      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29940      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29941      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29942    }
29943
29944    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29945
29946    function filterRows(status, btn) {
29947      activeStatusFilter = status;
29948      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29949        b.classList.remove('active');
29950      });
29951      if (btn) btn.classList.add('active');
29952      applyDeltaQuery();
29953    }
29954
29955    // ── Sorting ──────────────────────────────────────────────────────────────
29956    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29957    sortHeaders.forEach(function(th) {
29958      th.addEventListener('click', function(e) {
29959        if (e.target.classList.contains('col-resize-handle')) return;
29960        var col = th.dataset.sortCol;
29961        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29962        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29963        th.classList.add('sort-' + sortOrder);
29964        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29965        applyDeltaQuery();
29966      });
29967    });
29968
29969    // ── Column resize ─────────────────────────────────────────────────────────
29970    (function() {
29971      var table = document.getElementById('delta-table');
29972      if (!table) return;
29973      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29974      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29975      ths.forEach(function(th, i) {
29976        var handle = th.querySelector('.col-resize-handle');
29977        if (!handle || !cols[i]) return;
29978        handle.addEventListener('mousedown', function(e) {
29979          e.stopPropagation(); e.preventDefault();
29980          // Lock every column to its current rendered px width and size the table
29981          // to the column total. With table-layout:fixed + width:100% the table is
29982          // pinned to the container, so widening one <col> only rebalances the rest
29983          // and the drag looks inert; pinning px widths lets the column actually
29984          // grow while the wrapper (overflow-x:auto) scrolls.
29985          var startTableW = 0;
29986          for (var k = 0; k < ths.length; k++) {
29987            if (!cols[k]) continue;
29988            var w = ths[k].getBoundingClientRect().width;
29989            cols[k].style.width = w + 'px';
29990            startTableW += w;
29991          }
29992          table.style.width = startTableW + 'px';
29993          var startX = e.clientX;
29994          var startW = ths[i].getBoundingClientRect().width;
29995          handle.classList.add('dragging');
29996          function onMove(ev) {
29997            var newW = Math.max(40, startW + ev.clientX - startX);
29998            cols[i].style.width = newW + 'px';
29999            table.style.width = (startTableW + (newW - startW)) + 'px';
30000          }
30001          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30002          document.addEventListener('mousemove', onMove);
30003          document.addEventListener('mouseup', onUp);
30004        });
30005      });
30006    })();
30007
30008    // ── Reset ─────────────────────────────────────────────────────────────────
30009    window.resetDeltaTable = function() {
30010      sortCol = null; sortOrder = 'asc';
30011      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30012      var table = document.getElementById('delta-table');
30013      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30014      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30015      activeStatusFilter = 'all';
30016      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30017      var allBtn = document.querySelector('.tab-btn');
30018      if (allBtn) allBtn.classList.add('active');
30019      applyDeltaQuery();
30020    };
30021
30022    // Compact number formatter (shared by the delta table; charts define their own locally)
30023    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();}
30024    function fmtFull(n){return Number(n).toLocaleString();}
30025
30026    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30027    function fmtFromTo() {
30028      var tbody = document.getElementById('delta-tbody');
30029      if (!tbody) return;
30030      tbody.querySelectorAll('.delta-row').forEach(function(row) {
30031        var status = row.dataset.status || '';
30032        var ft = row.querySelector('.from-to');
30033        if (!ft) return;
30034        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30035        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30036        var strongs = ft.querySelectorAll('strong');
30037        // Apply fmt() to non-absent strong values
30038        strongs.forEach(function(el) {
30039          var n = parseInt(el.textContent, 10);
30040          if (!isNaN(n)) el.textContent = fmtFull(n);
30041        });
30042        // Safety: force dash for genuinely absent sides
30043        if (status === 'added' && bv === 0) {
30044          var bs = ft.querySelector('strong:first-of-type');
30045          if (bs && bs.textContent === '0') {
30046            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30047          }
30048        }
30049        if (status === 'removed' && cv === 0) {
30050          var cs = ft.querySelector('strong:last-of-type');
30051          if (cs && cs.textContent === '0') {
30052            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30053          }
30054        }
30055      });
30056    }
30057    // Initialize: format the server-rendered rows, lift them into the data model
30058    // (which also clears the DOM), then render only the first page.
30059    fmtFromTo();
30060    captureDelta();
30061    applyDeltaQuery();
30062
30063    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30064    (function() {
30065      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30066        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30067      });
30068      var resetBtn = document.getElementById('delta-reset-btn');
30069      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30070      var csvBtn = document.getElementById('delta-csv-btn');
30071      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30072      var xlsBtn = document.getElementById('delta-xls-btn');
30073      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30074      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30075      function sdFetchUri(path) {
30076        return fetch(path).then(function(r){return r.blob();}).then(function(b){
30077          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30078        }).catch(function(){return '';});
30079      }
30080      function sdInlineImgs(html, cb) {
30081        var paths=[], seen={};
30082        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30083        if(!paths.length){cb(html);return;}
30084        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30085          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30086          .catch(function(){cb(html);});
30087      }
30088      function buildFullPageHtml(pdfMode) {
30089        if(pdfMode) document.body.classList.add('pdf-mode');
30090        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30091        renderDeltaPage();
30092        var html = document.documentElement.outerHTML;
30093        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30094        if(pdfMode) document.body.classList.remove('pdf-mode');
30095        return html;
30096      }
30097      var chartsBtn = document.getElementById('delta-charts-btn');
30098      if (chartsBtn) chartsBtn.addEventListener('click', function() {
30099        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30100        sdInlineImgs(buildFullPageHtml(false), function(html) {
30101          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30102          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30103          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30104          btn.disabled=false;btn.innerHTML=orig;
30105        });
30106      });
30107      var pageHtmlBtn = document.getElementById('page-export-html-btn');
30108      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30109        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30110        sdInlineImgs(buildFullPageHtml(false), function(html) {
30111          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30112          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30113          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30114          btn.disabled=false;btn.innerHTML=orig;
30115        });
30116      });
30117      // PDF export — clean document-style report, not a web page screenshot
30118      function buildDeltaPdfHtml() {
30119        var sd=_sd, dr=getDeltaExportRows();
30120        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30121        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+'%';}
30122        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30123        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30124        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30125        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30126        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30127        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30128        function fmtN(n){return Number(n).toLocaleString();}
30129        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30130        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>';}
30131        var lm={};
30132        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;});
30133        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30134        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30135        // The header/footer flow in normal document order (NOT position:fixed).
30136        // A fixed header repeats on every printed page in Chromium and overlaps
30137        // the content beneath it — silently swallowing the first few table rows of
30138        // pages 2+ and clipping the summary cards on page 1. Letting the header
30139        // flow once at the top and relying on the table's <thead> (which Chromium
30140        // repeats per page) keeps every row visible. `.body` keeps a small inset
30141        // so nothing bleeds to the sheet edge.
30142        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30143          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30144          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30145          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30146          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30147          '.ph-brand em{color:#c45c10;font-style:normal;}'+
30148          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30149          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30150          '.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;}'+
30151          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30152          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30153          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30154          '.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;}'+
30155          '.body{padding:12px 18px 0;}'+
30156          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30157          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30158          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30159          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30160          '.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;}'+
30161          '.meta>div{flex:1 1 0;}'+
30162          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30163          '.sec{margin-bottom:10px;}'+
30164          '.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;}'+
30165          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30166          '.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;}'+
30167          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30168          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30169          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30170          '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;}'+
30171          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30172          'tr:nth-child(even) td{background:#faf8f6;}'+
30173          '.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;}'+
30174          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30175          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30176          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30177          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30178          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30179          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30180          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30181          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30182          '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30183          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30184          '.fcc-n{font-size:18px;font-weight:900;}'+
30185          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30186        var fileRows=dchg.map(function(r){
30187          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30188          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30189            '<td style="'+ss+'">'+esc(st)+'</td>'+
30190            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30191            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30192            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30193        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30194        var more='';
30195        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('');
30196        var extraCards='';
30197        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>';}
30198        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>';}
30199        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30200          '<div class="pdf-header">'+
30201          '<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>'+
30202          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30203          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30204          '</div>'+
30205          '<div class="body">'+
30206          '<div class="sec"><p class="sh">Summary Metrics</p>'+
30207          '<div class="msec">'+
30208          '<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>'+
30209          '<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>'+
30210          '<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>'+
30211          '<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>'+
30212          '<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>'+
30213          '<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>'+
30214          extraCards+'</div></div>'+
30215          '<div class="sec"><p class="sh">File Changes</p>'+
30216          '<div class="fcsec">'+
30217          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30218          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30219          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30220          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30221          '<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>'+
30222          '</div></div>'+
30223          (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>':'')+
30224          '<div class="sec">'+
30225          '<table><thead>'+
30226          '<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>'+
30227          '<tr><th>File</th><th>Language</th><th>Status</th>'+
30228          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30229          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30230          '</div>'+
30231          '<div class="rfoot">'+
30232          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30233          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30234          '</div>'+
30235          '</body></html>';
30236      }
30237      function doDeltaPdf(btn) {
30238        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30239      }
30240      var pdfBtn = document.getElementById('delta-pdf-btn');
30241      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30242      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30243      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30244      if (location.protocol === 'file:') {
30245        [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'; } });
30246        [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'; } });
30247      }
30248      var ppSel = document.getElementById('per-page-sel');
30249      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30250      var pathLink = document.getElementById('project-path-link');
30251      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30252    })();
30253
30254    // ── Export helpers ────────────────────────────────────────────────────────
30255    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
30256    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30257    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);}
30258    function slocMakeXlsx(fname,sd,dr){
30259      var enc=new TextEncoder();
30260      // CRC-32 table
30261      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;}
30262      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;}
30263      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30264      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30265      // Shared string table
30266      var ss=[],si={};
30267      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30268      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30269      // Worksheet builder — each WS() call gets its own row counter R
30270      function WS(){
30271        var R=0,buf=[];
30272        function cl(c){return String.fromCharCode(65+c);}
30273        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30274          '<v>'+S(v)+'</v></c>';}
30275        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30276          (st?' s="'+st+'"':'')+'>'+
30277          '<v>'+(+v)+'</v></c>';}
30278        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30279        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30280          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30281          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30282          '<sheetFormatPr defaultRowHeight="15"/>'+
30283          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30284        return{sc:sc,nc:nc,row:row,xml:xml};
30285      }
30286      // Language breakdown
30287      var lm={};
30288      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;});
30289      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30290      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30291      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30292      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30293      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30294      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30295      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):'';}
30296      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30297      // Summary sheet
30298      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30299      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30300      r1(s1(0,proj,2));
30301      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30302      r1('');
30303      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30304      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))));
30305      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))));
30306      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))));
30307      r1('');
30308      r1(s1(0,'FILE CHANGES',8));
30309      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30310      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30311      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30312      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30313      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30314      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)));
30315      if(langs.length){
30316        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30317        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30318        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)));});
30319      }
30320      r1('');r1(s1(0,'SCAN METADATA',8));
30321      r1(s1(1,_blabel)+s1(2,_clabel));
30322      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30323      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30324      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"/>');
30325      // File Delta sheet
30326      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30327      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));
30328      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)));});
30329      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30330      // Shared strings XML
30331      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30332        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30333        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30334      // XLSX file map
30335      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30336      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>',
30337        '_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>',
30338        '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>',
30339        '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>',
30340        '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>',
30341        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30342      // ZIP packer — STORED (no compression), compatible with all XLSX readers
30343      var zparts=[],zcds=[],zoff=0,znf=0;
30344      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30345       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30346      ].forEach(function(name){
30347        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30348        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]);
30349        var entry=new Uint8Array(lha.length+nb.length+sz);
30350        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30351        zparts.push(entry);
30352        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));
30353        var cde=new Uint8Array(cda.length+nb.length);
30354        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30355        zcds.push(cde);zoff+=entry.length;znf++;
30356      });
30357      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30358      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]);
30359      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30360      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30361      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30362      zout.set(new Uint8Array(ea),zpos);
30363      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30364      var xurl=URL.createObjectURL(xblob);
30365      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30366      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30367      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30368    }
30369    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;');}
30370    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30371    function getExportFilename(ext){return _exportBase+'.'+ext;}
30372
30373    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 }}'};
30374    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;}
30375    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30376    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30377    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30378    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30379    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):'';}
30380    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30381    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)]];}
30382    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30383    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)];});}
30384    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30385    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30386
30387    // ── Chart HTML report ─────────────────────────────────────────────────────
30388    function slocChartReport(fname, sd, dr) {
30389      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30390      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30391      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30392      function fmt(n){return Number(n).toLocaleString();}
30393      function px(n){return Math.round(n);}
30394      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30395      // Language map
30396      var lm={};
30397      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;});
30398      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30399
30400      // Builds onmouse* attrs for interactive tooltip on each SVG element
30401      function barTT(label,val){
30402        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30403      }
30404
30405      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30406      //    match the Language Code Delta column height) ────────────
30407      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'}];
30408      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30409      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30410      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30411      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30412      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"/>';}
30413      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30414      c1mets.forEach(function(m,i){
30415        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30416        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30417        var gMax=Math.max(m.b,m.c)*1.15||1;
30418        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30419        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>';
30420        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))+'/>';
30421        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>';
30422        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))+'/>';
30423        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>';
30424        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>';
30425        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>';
30426      });
30427      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>';
30428      c1+='</svg>';
30429
30430      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30431      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'}];
30432      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30433      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30434      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30435      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30436      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30437      mets.forEach(function(m,i){
30438        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30439        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30440        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30441        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>';
30442        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30443        if(bw>=52){
30444          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>';
30445        }else{
30446          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30447          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>';
30448        }
30449      });
30450      c2+='</svg>';
30451
30452      // ── Chart 3: Language Code Delta ─────────────────────────────────────
30453      var c3='';
30454      if(langs.length){
30455        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30456        var C3W=550,c3LW=124,c3FW=52;
30457        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30458        var L3rH=30,C3H=langs.length*L3rH+20;
30459        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30460        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30461        langs.forEach(function(l,i){
30462          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30463          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30464          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30465          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>';
30466          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':''))+'/>';
30467          if(bw>=48){
30468            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>';
30469          }else{
30470            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30471            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>';
30472          }
30473          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>';
30474        });
30475        c3+='</svg>';
30476      }
30477
30478      // ── Chart 4: File Change Donut — centered pie with legend below
30479      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;});
30480      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30481      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30482      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">';
30483      var ang=-Math.PI/2;
30484      segs.forEach(function(s){
30485        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30486        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30487        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30488        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30489        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30490        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)+'%')+'/>';
30491        ang+=sw;
30492      });
30493      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>';
30494      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>';
30495      segs.forEach(function(s,i){
30496        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30497        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30498        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>';
30499      });
30500      c4+='</svg>';
30501
30502      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30503      var ttJs='var tt=document.getElementById("ox-tt");'+
30504        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30505        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30506        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30507        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30508        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30509        'function oxHT(){tt.style.display="none";}';
30510
30511      // body max-width keeps charts from inflating beyond design dimensions on
30512      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30513      // each chart's height blows up proportionally, breaking the one-page layout.
30514      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;}'+
30515        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30516        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30517        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30518        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30519        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30520        'svg{display:block;}'+
30521        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30522        '#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;}'+
30523        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30524      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30525        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30526        '<div id="ox-tt"><\/div>'+
30527        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
30528        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
30529        '<div class="two-col">'+
30530        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
30531        '<div class="leg">'+
30532        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30533        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30534        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30535        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
30536        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30537        '<\/div>'+
30538        '<div class="two-col">'+
30539        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30540        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30541        '<\/div>'+
30542        '<script>'+ttJs+'<\/script>'+
30543        '<\/body><\/html>';
30544      slocDownload(html, fname, 'text/html;charset=utf-8;');
30545    }
30546    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30547    window.buildDeltaChartsHtml = function() {
30548      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30549      var sd=_sd;
30550      var projEl=document.querySelector('[data-folder]');
30551      var proj=projEl?projEl.getAttribute('data-folder'):'';
30552      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30553      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30554      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30555      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30556      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";}';
30557      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);}';
30558      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30559        '<div id="ox-tt"><\/div>'+
30560        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30561        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30562        '<div class="two-col">'+
30563        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30564        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30565        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30566        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30567        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30568        '<\/div>'+
30569        '<div class="two-col">'+
30570        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30571        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30572        '<\/div>'+
30573        '<script>'+ttJs+'<\/script>'+
30574        '<\/body><\/html>';
30575    };
30576    // ── Inline delta charts ────────────────────────────────────────────────────
30577    var _icTT=document.getElementById('ic-tt');
30578    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30579    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';};
30580    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30581    window.addEventListener('blur',function(){window.icHT();});
30582    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30583    (function(){
30584      // Theme-aware palette — matches the canonical scheme used by /test-metrics
30585      // charts so every page renders bars/text/grid with the same colours and
30586      // adapts to dark mode (see Design section in CLAUDE.md).
30587      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30588      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30589      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30590      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30591      // washed) so the chart reads with the same weight as /test-metrics.
30592      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30593      var FADE=dark?'#524238':'#e6d0bf';
30594      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30595      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30596      function fmt(n){return Number(n).toLocaleString();}
30597      function px(n){return Math.round(n);}
30598      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30599      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30600      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);});}
30601      var dr=getDeltaExportRows(),sd=_sd,lm={};
30602      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;});
30603      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30604      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30605      // the bars are as tall as the (usually taller) Language Code Delta sibling that
30606      // shares the same grid row, instead of sitting short at the top.
30607      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}];
30608      function drawC1(){
30609        var C1W=600,C1H=188;
30610        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30611        if(host&&card&&host.clientWidth>0){
30612          var avW=host.clientWidth;
30613          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30614          var wantH=availPx*C1W/avW;
30615          if(wantH>C1H)C1H=wantH;
30616        }
30617        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30618        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30619        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"/>';}
30620        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30621        c1mets.forEach(function(m,i){
30622          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30623          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30624          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30625          var gMax=Math.max(m.b,m.c)*1.15||1;
30626          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30627          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>';
30628          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"/>';
30629          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>';
30630          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"/>';
30631          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>';
30632          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>';
30633          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>';
30634        });
30635        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>';
30636        c1+='</svg>';
30637        return c1;
30638      }
30639      var c1=drawC1();
30640      // Chart 2: Delta by Metric
30641      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}];
30642      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30643      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;
30644      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30645      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30646      mets.forEach(function(m,i){
30647        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);
30648        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>';
30649        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30650        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>';}
30651        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>';}
30652      });
30653      c2+='</svg>';
30654      // Chart 3: Language Code Delta
30655      var c3='';
30656      if(langs.length){
30657        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30658        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;
30659        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30660        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30661        langs.forEach(function(l,i){
30662          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);
30663          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>';
30664          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"/>';
30665          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>';}
30666          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>';}
30667          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>';
30668        });
30669        c3+='</svg>';
30670      }
30671      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30672      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30673      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;});
30674      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30675      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30676      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);
30677      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;
30678      if(segs.length===1){
30679        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30680        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+'"/>';
30681      } else {
30682        // Give every visible slice a small minimum sweep, taken from the largest
30683        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30684        // arc whose start and end points coincide, so SVG renders nothing (blank).
30685        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30686        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30687        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30688        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30689        segs.forEach(function(s,si){
30690          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30691          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);
30692          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);
30693          var pct=Math.round(s.v/tot*100);
30694          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"/>';
30695          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>';}
30696          ang+=sw;
30697        });
30698      }
30699      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30700      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30701      segs.forEach(function(s,i){
30702        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30703        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30704        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30705        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30706        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30707        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>';
30708        c4+='</g>';
30709      });
30710      c4+='</svg>';
30711      // Inject the fixed-height siblings first so the grid row settles to the (taller)
30712      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30713      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30714      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);}
30715      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30716      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30717      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30718
30719      // Compare Timeline chart (Baseline vs Current, 2 points)
30720      (function() {
30721        var activeCmpMetric='code';
30722        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30723        function renderCmpTL(metric, targetSvg, targetH) {
30724          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30725          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30726          svg.setAttribute('height',H);
30727          var pad={l:62,r:20,t:32,b:72};
30728          var dark=document.body.classList.contains('dark-theme');
30729          var cmpPts=[
30730            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30731            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30732          ];
30733          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30734          var valid=pts.filter(function(v){return v!=null;});
30735          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;}
30736          var minV=0,maxV=Math.max.apply(null,valid);
30737          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30738          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30739          var cx0=pad.l,cx1=pad.l+plotW;
30740          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30741          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30742          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30743          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30744          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30745          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();}
30746          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30747          var parts=[];
30748          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30749          for(var gi=0;gi<5;gi++){
30750            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30751            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30752            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30753          }
30754          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+'"/>');
30755          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"/>');
30756          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30757                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30758          dotPts.forEach(function(pt){
30759            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>');
30760            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"/>');
30761            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>');
30762            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>');
30763          });
30764          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30765          svg.setAttribute('viewBox','0 0 '+W+' '+H);
30766          svg.innerHTML=parts.join('');
30767          // Hover: crosshair + tooltip (matches multi-scan timeline)
30768          var cmpTT=document.getElementById('ic-tt');
30769          svg.onmousemove=function(e){
30770            var rect=svg.getBoundingClientRect();
30771            var scaleX=W/rect.width;
30772            var mouseX=(e.clientX-rect.left)*scaleX;
30773            var nearest=-1,minDist=Infinity;
30774            var cxArr=[cx0,cx1];
30775            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;}}
30776            if(nearest<0)return;
30777            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30778            var xhair=svg.querySelector('.cmp-xhair');
30779            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30780            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"/>';
30781            if(!cmpTT)return;
30782            var clbl=cmpPts[nearest].label;
30783            var scanLbl=nearest===0?'Baseline':'Current';
30784            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>';
30785            var bx=rect.left+(nc/W*rect.width)+18;
30786            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30787            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30788          };
30789          svg.onmouseleave=function(){
30790            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30791            if(cmpTT)cmpTT.style.display='none';
30792          };
30793        }
30794        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30795          btn.addEventListener('click',function(){
30796            activeCmpMetric=this.dataset.cmpMetric;
30797            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30798            this.classList.add('active');
30799            renderCmpTL(activeCmpMetric);
30800          });
30801        });
30802        var ttgl=document.getElementById('theme-toggle');
30803        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30804        if(typeof ResizeObserver!=='undefined'){
30805          var cmpSvg=document.getElementById('cmp-tl-svg');
30806          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30807        }
30808        // Expose the timeline renderer + current metric so the Full View modal can
30809        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30810        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30811        window.__sdGetMetric=function(){return activeCmpMetric;};
30812        renderCmpTL(activeCmpMetric);
30813      })();
30814
30815      // HTML legend hover -> highlight matching SVG bars within the SAME card only
30816      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30817        var metric=leg.getAttribute('data-highlight');
30818        var parentCard=leg.closest('.ic-card');
30819        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30820        if(!chartEl)return;
30821        leg.addEventListener('mouseenter',function(){
30822          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30823            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';}
30824            else{x.style.opacity='0.28';}
30825          });
30826        });
30827        leg.addEventListener('mouseleave',function(){
30828          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30829        });
30830      });
30831
30832      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30833      (function(){
30834        var ov=document.getElementById('ic-svg-modal-ov');
30835        var body=document.getElementById('ic-svg-modal-body');
30836        var ttl=document.getElementById('ic-svg-modal-title');
30837        var closeBtn=document.getElementById('ic-svg-modal-close');
30838        if(!ov||!body)return;
30839        function close(){
30840          ov.classList.remove('open');body.innerHTML='';
30841          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30842          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30843        }
30844        function open(srcId,title){
30845          var src=document.getElementById(srcId);if(!src)return;
30846          if(ttl)ttl.textContent=title||'';
30847          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30848          // snapshot stretches and loses interactivity. Re-render it live into the modal at
30849          // full size instead — keeps proportions, animation, crosshair, tooltip and the
30850          // metric tabs working exactly like the inline chart.
30851          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30852            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30853            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30854            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('');
30855            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>';
30856            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30857            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30858            ov.classList.add('open');
30859            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30860            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;}
30861            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30862              b.addEventListener('click',function(){
30863                if(!window.__sdFvTL)return;
30864                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30865                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30866                this.classList.add('active');
30867                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30868              });
30869            });
30870            return;
30871          }
30872          var card=src.closest('.ic-card');
30873          var legHtml='';
30874          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30875          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30876          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;}
30877          body.innerHTML=legHtml+inner;
30878          var svg=body.querySelector('svg');
30879          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30880          addTT(body);
30881          ov.classList.add('open');
30882        }
30883        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30884          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30885        });
30886        if(closeBtn)closeBtn.addEventListener('click',close);
30887        ov.addEventListener('click',function(e){if(e.target===ov)close();});
30888        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30889      })();
30890
30891      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30892    })();
30893  </script>
30894  {{ toast_assets|safe }}
30895  <script nonce="{{ csp_nonce }}">
30896  (function(){
30897    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'}];
30898    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);});}
30899    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30900    function init(){
30901      var btn=document.getElementById('settings-btn');if(!btn)return;
30902      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30903      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>';
30904      document.body.appendChild(m);
30905      var g=document.getElementById('scheme-grid');
30906      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);});
30907      var cl=document.getElementById('settings-close');
30908      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);});})();
30909      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');});
30910      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30911      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30912    }
30913    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30914  }());
30915  </script>
30916  <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]';
30917  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;}
30918  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>
30919</body>
30920</html>
30921"##,
30922    ext = "html"
30923)]
30924// Template structs need many bool fields to pass Askama rendering flags.
30925#[allow(clippy::struct_excessive_bools)]
30926struct CompareTemplate {
30927    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
30928    loading_overlay: String,
30929    version: &'static str,
30930    project_label: String,
30931    baseline_git_commit: String,
30932    current_git_commit: String,
30933    baseline_run_id: String,
30934    current_run_id: String,
30935    baseline_run_id_short: String,
30936    current_run_id_short: String,
30937    baseline_timestamp: String,
30938    baseline_timestamp_utc_ms: i64,
30939    current_timestamp: String,
30940    current_timestamp_utc_ms: i64,
30941    project_path: String,
30942    baseline_code: u64,
30943    current_code: u64,
30944    code_lines_delta_str: String,
30945    code_lines_delta_class: String,
30946    baseline_files: u64,
30947    current_files: u64,
30948    files_analyzed_delta_str: String,
30949    files_analyzed_delta_class: String,
30950    baseline_comments: u64,
30951    current_comments: u64,
30952    comment_lines_delta_str: String,
30953    comment_lines_delta_class: String,
30954    baseline_code_fmt: String,
30955    current_code_fmt: String,
30956    baseline_files_fmt: String,
30957    current_files_fmt: String,
30958    baseline_comments_fmt: String,
30959    current_comments_fmt: String,
30960    code_lines_pct_str: String,
30961    files_analyzed_pct_str: String,
30962    comment_lines_pct_str: String,
30963    code_lines_added: i64,
30964    code_lines_removed: i64,
30965    /// Code lines residing in files modified between the two scans (current-scan counts).
30966    code_lines_modified: i64,
30967    /// Code lines residing in files identical between the two scans.
30968    code_lines_unmodified: i64,
30969    /// Sum of added + removed + modified + unmodified code-line metrics.
30970    code_lines_total: i64,
30971    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
30972    new_scope: bool,
30973    churn_rate_str: String,
30974    churn_rate_class: String,
30975    scope_flag: bool,
30976    files_added: usize,
30977    files_removed: usize,
30978    files_modified: usize,
30979    files_unchanged: usize,
30980    files_total: usize,
30981    file_rows: Vec<CompareFileDeltaRow>,
30982    baseline_git_author: Option<String>,
30983    current_git_author: Option<String>,
30984    baseline_git_branch: String,
30985    current_git_branch: String,
30986    baseline_git_tags: Option<String>,
30987    current_git_tags: Option<String>,
30988    baseline_git_commit_date: Option<String>,
30989    current_git_commit_date: Option<String>,
30990    project_name: String,
30991    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
30992    submodule_options: Vec<String>,
30993    /// True when either run has submodule data — controls whether the scope bar is shown.
30994    has_any_submodule_data: bool,
30995    /// The submodule currently being compared, if the `sub` query param was provided.
30996    active_submodule: Option<String>,
30997    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
30998    super_scope_active: bool,
30999    csp_nonce: String,
31000    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
31001    toast_assets: String,
31002    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
31003    coverage_delta_card: String,
31004    baseline_test_count: u64,
31005    current_test_count: u64,
31006    baseline_coverage_pct: Option<f64>,
31007    current_coverage_pct: Option<f64>,
31008}
31009
31010// ── LoginTemplate ──────────────────────────────────────────────────────────────
31011
31012#[derive(Template)]
31013#[template(
31014    source = r##"
31015<!doctype html>
31016<html lang="en">
31017<head>
31018  <meta charset="utf-8">
31019  <meta name="viewport" content="width=device-width, initial-scale=1">
31020  <title>OxideSLOC | Sign In</title>
31021  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31022  <style nonce="{{ csp_nonce }}">
31023    :root {
31024      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31025      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31026      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31027      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31028    }
31029    *{box-sizing:border-box;}
31030    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);}
31031    .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);}
31032    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31033    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31034    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31035    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31036    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31037    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31038    .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;}
31039    @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));}}
31040    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31041    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31042    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31043    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31044    .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;}
31045    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31046    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;}
31047    input[type=password]:focus{border-color:var(--oxide);}
31048    .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;}
31049    .btn:hover{opacity:.88;}
31050    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31051    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31052  </style>
31053</head>
31054<body>
31055  <div class="background-watermarks" aria-hidden="true">
31056    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31057    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31058    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31059    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31060    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31061    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31062    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31063  </div>
31064  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31065<nav class="top-nav">
31066  <a class="brand" href="/">
31067    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31068    <span class="brand-title">OxideSLOC</span>
31069  </a>
31070</nav>
31071<main class="page">
31072  <div class="card">
31073    <h1>Sign In</h1>
31074    <p class="subtitle">Enter the API key printed when the server started.</p>
31075    {% if has_error %}
31076    <div class="error">Incorrect API key — please try again.</div>
31077    {% endif %}
31078    <form method="POST" action="/auth/login">
31079      <input type="hidden" name="next" value="{{ next_url|e }}">
31080      <label for="key">API Key</label>
31081      <input id="key" type="password" name="key" autocomplete="current-password"
31082             placeholder="Paste your API key here" autofocus>
31083      <button type="submit" class="btn">Sign In</button>
31084    </form>
31085    <p class="hint">
31086      The API key was printed in the terminal when the server started.<br>
31087      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31088      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31089    </p>
31090  </div>
31091</main>
31092<script nonce="{{ csp_nonce }}">
31093(function() {
31094  (function randomizeWatermarks() {
31095    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31096    if (!wms.length) return;
31097    var placed = [];
31098    function tooClose(top, left) {
31099      for (var i = 0; i < placed.length; i++) {
31100        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31101        if (dt < 16 && dl < 12) return true;
31102      }
31103      return false;
31104    }
31105    function pick(leftBand) {
31106      for (var attempt = 0; attempt < 50; attempt++) {
31107        var top = Math.random() * 88 + 2;
31108        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31109        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31110      }
31111      var top = Math.random() * 88 + 2;
31112      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31113      placed.push([top, left]); return [top, left];
31114    }
31115    var half = Math.floor(wms.length / 2);
31116    wms.forEach(function (img, i) {
31117      var pos = pick(i < half);
31118      var size = Math.floor(Math.random() * 100 + 120);
31119      var rot = (Math.random() * 360).toFixed(1);
31120      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31121      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;
31122    });
31123  })();
31124  (function spawnCodeParticles() {
31125    var container = document.getElementById('code-particles');
31126    if (!container) return;
31127    var snippets = [
31128      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31129      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31130      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31131      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31132      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31133    ];
31134    var count = 38;
31135    for (var i = 0; i < count; i++) {
31136      (function(idx) {
31137        var el = document.createElement('span');
31138        el.className = 'code-particle';
31139        el.textContent = snippets[idx % snippets.length];
31140        var left = Math.random() * 94 + 2;
31141        var top = Math.random() * 88 + 6;
31142        var dur = (Math.random() * 10 + 9).toFixed(1);
31143        var delay = (Math.random() * 18).toFixed(1);
31144        var rot = (Math.random() * 26 - 13).toFixed(1);
31145        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31146        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31147        container.appendChild(el);
31148      })(i);
31149    }
31150  })();
31151})();
31152</script>
31153</body>
31154</html>
31155"##,
31156    ext = "html"
31157)]
31158pub(crate) struct LoginTemplate {
31159    pub(crate) csp_nonce: String,
31160    pub(crate) has_error: bool,
31161    pub(crate) next_url: String,
31162    pub(crate) lockout_threshold: u32,
31163}
31164
31165// ── REST API reference page ────────────────────────────────────────────────────
31166
31167#[derive(Template)]
31168#[template(
31169    source = r##"
31170<!doctype html>
31171<html lang="en">
31172<head>
31173  <meta charset="utf-8">
31174  <meta name="viewport" content="width=device-width, initial-scale=1">
31175  <title>OxideSLOC — REST API Reference</title>
31176  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31177  <style nonce="{{ csp_nonce }}">
31178    :root {
31179      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31180      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31181      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31182      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31183      --success:#16a34a;
31184    }
31185    body.dark-theme {
31186      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31187      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31188    }
31189    *{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;}
31190    .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);}
31191    .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;}
31192    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31193    .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));}
31194    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31195    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31196    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31197    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31198    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31199    @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; } }
31200    .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;}
31201    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31202    .nav-pill.active{background:rgba(255,255,255,0.22);}
31203    .nav-dropdown{position:relative;display:inline-flex;}
31204    .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;}
31205    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31206    .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;}
31207    .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;}
31208    .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);}
31209    .nav-dropdown-menu a:last-child{border-bottom:none;}
31210    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31211    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31212    .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;}
31213    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31214    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31215    .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;}
31216    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31217    .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);}
31218    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31219    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31220    .settings-modal-body{padding:14px 16px 16px;}
31221    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31222    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31223    .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;}
31224    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31225    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31226    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31227    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31228    .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;}
31229    .tz-select:focus{border-color:var(--oxide);}
31230    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31231    .page-header{margin-bottom:28px;}
31232    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31233    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31234    .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;}
31235    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31236    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31237    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31238    .callout strong{font-weight:800;}
31239    .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;}
31240    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31241    .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;}
31242    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31243    .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;}
31244    body.dark-theme .base-url-value{color:var(--accent);}
31245    .section{margin-bottom:36px;}
31246    .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);}
31247    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31248    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31249    .ep-header:hover{background:var(--surface-2);}
31250    .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;}
31251    .method.get{background:#dcfce7;color:#166534;}
31252    .method.post{background:#dbeafe;color:#1e40af;}
31253    .method.delete{background:#fee2e2;color:#991b1b;}
31254    body.dark-theme .method.get{background:#14532d;color:#86efac;}
31255    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31256    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31257    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31258    .ep-path .param{color:var(--oxide-2);}
31259    body.dark-theme .ep-path .param{color:var(--oxide);}
31260    .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;}
31261    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31262    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31263    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31264    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31265    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31266    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31267    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31268    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31269    .ep-card.open .chevron{transform:rotate(180deg);}
31270    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31271    .ep-card.open .ep-body{display:block;}
31272    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31273    .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;}
31274    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31275    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31276    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31277    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31278    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);}
31279    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31280    table.params tr:last-child td{border-bottom:none;}
31281    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31282    .pt-type{color:var(--muted-2);font-size:12px;}
31283    .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;}
31284    .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;}
31285    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31286    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31287    details.schema{margin-bottom:14px;}
31288    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;}
31289    details.schema summary:hover{color:var(--text);}
31290    .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;}
31291    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31292    .curl-wrap{position:relative;}
31293    .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;}
31294    .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;}
31295    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31296    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31297    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31298    .webhook-note a{color:var(--accent-2);text-decoration:none;}
31299    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31300    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31301    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31302    .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;}
31303    @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));}}
31304    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31305    .site-footer a{color:var(--muted);}
31306  </style>
31307</head>
31308<body>
31309  <div class="background-watermarks" aria-hidden="true">
31310    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31311    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31312    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31313    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31314    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31315    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31316    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31317  </div>
31318  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31319  <div class="top-nav">
31320    <div class="top-nav-inner">
31321      <a class="brand" href="/">
31322        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31323        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31324      </a>
31325      <div class="nav-right">
31326        <a class="nav-pill" href="/">Home</a>
31327        <div class="nav-dropdown">
31328          <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>
31329          <div class="nav-dropdown-menu">
31330            <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>
31331          </div>
31332        </div>
31333        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31334        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31335        <div class="nav-dropdown">
31336          <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>
31337          <div class="nav-dropdown-menu">
31338            <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>
31339          </div>
31340        </div>
31341        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31342          <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>
31343        </button>
31344        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31345          <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>
31346          <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>
31347        </button>
31348      </div>
31349    </div>
31350  </div>
31351
31352  <div class="page">
31353    <div class="page-header">
31354      <h1 class="page-title">REST API Reference</h1>
31355      <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>
31356    </div>
31357
31358    {% if has_api_key %}
31359    <div class="callout key-set">
31360      <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>
31361      <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>
31362    </div>
31363    {% else %}
31364    <div class="callout no-key">
31365      <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>
31366      <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>
31367    </div>
31368    {% endif %}
31369
31370    <div class="base-url-bar">
31371      <span class="base-url-label">Base URL</span>
31372      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31373    </div>
31374
31375    <!-- Health -->
31376    <div class="section">
31377      <h2 class="section-title">Health &amp; Status</h2>
31378      <div class="ep-card">
31379        <div class="ep-header">
31380          <span class="method get">GET</span>
31381          <span class="ep-path">/healthz</span>
31382          <span class="auth-badge public">Public</span>
31383          <span class="ep-desc">Server liveness check</span>
31384          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31385        </div>
31386        <div class="ep-body">
31387          <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>
31388          <p class="params-heading">Response</p>
31389          <div class="schema-block">200 OK
31390Content-Type: text/plain
31391
31392ok</div>
31393          <p class="curl-heading">Example</p>
31394          <div class="curl-wrap">
31395            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31396            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31397          </div>
31398        </div>
31399      </div>
31400    </div>
31401
31402    <!-- Badges -->
31403    <div class="section">
31404      <h2 class="section-title">Badges</h2>
31405      <div class="ep-card">
31406        <div class="ep-header">
31407          <span class="method get">GET</span>
31408          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31409          <span class="auth-badge public">Public</span>
31410          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31411          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31412        </div>
31413        <div class="ep-body">
31414          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31415          <p class="params-heading">Path Parameters</p>
31416          <table class="params">
31417            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31418            <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>
31419          </table>
31420          <p class="curl-heading">Example</p>
31421          <div class="curl-wrap">
31422            <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>
31423            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31424          </div>
31425        </div>
31426      </div>
31427    </div>
31428
31429    <!-- Metrics -->
31430    <div class="section">
31431      <h2 class="section-title">Metrics</h2>
31432
31433      <div class="ep-card">
31434        <div class="ep-header">
31435          <span class="method get">GET</span>
31436          <span class="ep-path">/api/metrics/latest</span>
31437          <span class="auth-badge protected">Protected</span>
31438          <span class="ep-desc">Latest scan metrics (JSON)</span>
31439          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31440        </div>
31441        <div class="ep-body">
31442          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31443          <details class="schema"><summary>Response schema</summary>
31444<div class="schema-block">{
31445  "run_id":    string,        // UUID
31446  "timestamp": string,        // ISO-8601 UTC
31447  "project":   string,        // scanned root path
31448  "summary": {
31449    "files_analyzed":       number,
31450    "files_skipped":        number,
31451    "code_lines":           number,
31452    "comment_lines":        number,
31453    "blank_lines":          number,
31454    "total_physical_lines": number,
31455    "functions":            number,
31456    "classes":              number,
31457    "variables":            number,
31458    "imports":              number
31459  },
31460  "languages": [
31461    { "name": string, "files": number, "code_lines": number,
31462      "comment_lines": number, "blank_lines": number,
31463      "functions": number, "classes": number,
31464      "variables": number, "imports": number }
31465  ]
31466}</div></details>
31467          <p class="curl-heading">Example</p>
31468          <div class="curl-wrap">
31469            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31470  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31471            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31472          </div>
31473        </div>
31474      </div>
31475
31476      <div class="ep-card">
31477        <div class="ep-header">
31478          <span class="method get">GET</span>
31479          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31480          <span class="auth-badge protected">Protected</span>
31481          <span class="ep-desc">Metrics for a specific run</span>
31482          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31483        </div>
31484        <div class="ep-body">
31485          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31486          <p class="params-heading">Path Parameters</p>
31487          <table class="params">
31488            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31489            <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>
31490          </table>
31491          <p class="curl-heading">Example</p>
31492          <div class="curl-wrap">
31493            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31494  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
31495            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31496          </div>
31497        </div>
31498      </div>
31499
31500      <div class="ep-card">
31501        <div class="ep-header">
31502          <span class="method get">GET</span>
31503          <span class="ep-path">/api/metrics/history</span>
31504          <span class="auth-badge protected">Protected</span>
31505          <span class="ep-desc">Paginated scan history</span>
31506          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31507        </div>
31508        <div class="ep-body">
31509          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31510          <p class="params-heading">Query Parameters</p>
31511          <table class="params">
31512            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31513            <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>
31514            <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>
31515          </table>
31516          <details class="schema"><summary>Response schema</summary>
31517<div class="schema-block">[{
31518  "run_id":         string,
31519  "timestamp":      string,   // ISO-8601 UTC
31520  "commit":         string | null,
31521  "branch":         string | null,
31522  "tags":           string[],
31523  "code_lines":     number,
31524  "comment_lines":  number,
31525  "blank_lines":    number,
31526  "physical_lines": number,
31527  "files_analyzed": number,
31528  "project_label":  string,
31529  "html_url":       string | null
31530}]</div></details>
31531          <p class="curl-heading">Example</p>
31532          <div class="curl-wrap">
31533            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31534  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31535            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31536          </div>
31537        </div>
31538      </div>
31539
31540      <div class="ep-card">
31541        <div class="ep-header">
31542          <span class="method get">GET</span>
31543          <span class="ep-path">/api/project-history</span>
31544          <span class="auth-badge protected">Protected</span>
31545          <span class="ep-desc">Project-level scan summary</span>
31546          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31547        </div>
31548        <div class="ep-body">
31549          <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>
31550          <p class="params-heading">Query Parameters</p>
31551          <table class="params">
31552            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31553            <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>
31554          </table>
31555          <details class="schema"><summary>Response schema</summary>
31556<div class="schema-block">{
31557  "scan_count":           number,
31558  "last_scan_id":         string | null,
31559  "last_scan_timestamp":  string | null,  // ISO-8601
31560  "last_scan_code_lines": number | null,
31561  "last_git_branch":      string | null,
31562  "last_git_commit":      string | null
31563}</div></details>
31564          <p class="curl-heading">Example</p>
31565          <div class="curl-wrap">
31566            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31567  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31568            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31569          </div>
31570        </div>
31571      </div>
31572
31573      <div class="ep-card">
31574        <div class="ep-header">
31575          <span class="method get">GET</span>
31576          <span class="ep-path">/api/metrics/submodules</span>
31577          <span class="auth-badge protected">Protected</span>
31578          <span class="ep-desc">List known git submodules across scans</span>
31579          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31580        </div>
31581        <div class="ep-body">
31582          <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>
31583          <p class="params-heading">Query Parameters</p>
31584          <table class="params">
31585            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31586            <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>
31587          </table>
31588          <details class="schema"><summary>Response schema</summary>
31589<div class="schema-block">[{
31590  "name":          string,  // submodule name
31591  "relative_path": string   // path relative to the project root
31592}]</div></details>
31593          <p class="curl-heading">Example</p>
31594          <div class="curl-wrap">
31595            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31596  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31597            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31598          </div>
31599        </div>
31600      </div>
31601    </div>
31602
31603    <!-- Async Run Status -->
31604    <div class="section">
31605      <h2 class="section-title">Async Run Status</h2>
31606
31607      <div class="ep-card">
31608        <div class="ep-header">
31609          <span class="method get">GET</span>
31610          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31611          <span class="auth-badge protected">Protected</span>
31612          <span class="ep-desc">Poll scan completion</span>
31613          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31614        </div>
31615        <div class="ep-body">
31616          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31617          <details class="schema"><summary>Response schema</summary>
31618<div class="schema-block">// Running
31619{ "state": "running",  "elapsed_secs": number }
31620
31621// Complete
31622{ "state": "complete", "run_id": string }
31623
31624// Failed
31625{ "state": "failed",   "message": string }</div></details>
31626          <p class="curl-heading">Example</p>
31627          <div class="curl-wrap">
31628            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31629  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
31630            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31631          </div>
31632        </div>
31633      </div>
31634
31635      <div class="ep-card">
31636        <div class="ep-header">
31637          <span class="method get">GET</span>
31638          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31639          <span class="auth-badge protected">Protected</span>
31640          <span class="ep-desc">Poll PDF generation readiness</span>
31641          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31642        </div>
31643        <div class="ep-body">
31644          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31645          <details class="schema"><summary>Response schema</summary>
31646<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31647          <p class="curl-heading">Example</p>
31648          <div class="curl-wrap">
31649            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31650  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
31651            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31652          </div>
31653        </div>
31654      </div>
31655
31656      <div class="ep-card">
31657        <div class="ep-header">
31658          <span class="method post">POST</span>
31659          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31660          <span class="auth-badge protected">Protected</span>
31661          <span class="ep-desc">Cancel a running scan</span>
31662          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31663        </div>
31664        <div class="ep-body">
31665          <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>
31666          <p class="curl-heading">Example</p>
31667          <div class="curl-wrap">
31668            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31669  -H "Authorization: Bearer $SLOC_API_KEY" \
31670  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
31671            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31672          </div>
31673        </div>
31674      </div>
31675    </div>
31676
31677    <!-- Run Management -->
31678    <div class="section">
31679      <h2 class="section-title">Run Management</h2>
31680
31681      <div class="ep-card">
31682        <div class="ep-header">
31683          <span class="method get">GET</span>
31684          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31685          <span class="auth-badge protected">Protected</span>
31686          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31687          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31688        </div>
31689        <div class="ep-body">
31690          <p class="ep-desc-full">Returns 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>
31691          <p class="params-heading">Path Parameters</p>
31692          <table class="params">
31693            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31694            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID from <code>/api/metrics/history</code></td></tr>
31695          </table>
31696          <details class="schema"><summary>Response</summary>
31697<div class="schema-block">200 OK — Content-Type: application/zip
31698Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
31699
31700404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
31701          <p class="curl-heading">Example</p>
31702          <div class="curl-wrap">
31703            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31704  -o run.zip \
31705  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
31706            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31707          </div>
31708        </div>
31709      </div>
31710
31711      <div class="ep-card">
31712        <div class="ep-header">
31713          <span class="method delete">DELETE</span>
31714          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31715          <span class="auth-badge protected">Protected</span>
31716          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31717          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31718        </div>
31719        <div class="ep-body">
31720          <p class="ep-desc-full">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>
31721          <p class="params-heading">Path Parameters</p>
31722          <table class="params">
31723            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31724            <tr><td class="pt-name">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>
31725          </table>
31726          <details class="schema"><summary>Response</summary>
31727<div class="schema-block">204 No Content — run successfully deleted
31728
31729500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
31730          <p class="curl-heading">Example</p>
31731          <div class="curl-wrap">
31732            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31733  -H "Authorization: Bearer $SLOC_API_KEY" \
31734  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
31735            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31736          </div>
31737        </div>
31738      </div>
31739
31740      <div class="ep-card">
31741        <div class="ep-header">
31742          <span class="method post">POST</span>
31743          <span class="ep-path">/api/runs/cleanup</span>
31744          <span class="auth-badge protected">Protected</span>
31745          <span class="ep-desc">Bulk delete runs older than N days</span>
31746          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31747        </div>
31748        <div class="ep-body">
31749          <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>
31750          <p class="params-heading">Request Body (application/json)</p>
31751          <table class="params">
31752            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31753            <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>
31754          </table>
31755          <details class="schema"><summary>Response schema</summary>
31756<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
31757          <p class="curl-heading">Example — delete runs older than 60 days</p>
31758          <div class="curl-wrap">
31759            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31760  -H "Authorization: Bearer $SLOC_API_KEY" \
31761  -H "Content-Type: application/json" \
31762  -d '{"older_than_days":60}' \
31763  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31764            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31765          </div>
31766        </div>
31767      </div>
31768    </div>
31769
31770    <!-- Retention Policy -->
31771    <div class="section">
31772      <h2 class="section-title">Retention Policy</h2>
31773
31774      <div class="ep-card">
31775        <div class="ep-header">
31776          <span class="method get">GET</span>
31777          <span class="ep-path">/api/cleanup-policy</span>
31778          <span class="auth-badge protected">Protected</span>
31779          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31780          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31781        </div>
31782        <div class="ep-body">
31783          <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>
31784          <details class="schema"><summary>Response schema</summary>
31785<div class="schema-block">{
31786  "policy": {
31787    "enabled":       boolean,
31788    "max_age_days":  number | null,   // delete runs older than N days
31789    "max_run_count": number | null,   // keep only the N most recent runs
31790    "interval_hours": number          // hours between background passes
31791  } | null,
31792  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
31793  "last_run_deleted": number | null   // runs deleted in last pass
31794}</div></details>
31795          <p class="curl-heading">Example</p>
31796          <div class="curl-wrap">
31797            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31798  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31799            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31800          </div>
31801        </div>
31802      </div>
31803
31804      <div class="ep-card">
31805        <div class="ep-header">
31806          <span class="method post">POST</span>
31807          <span class="ep-path">/api/cleanup-policy</span>
31808          <span class="auth-badge protected">Protected</span>
31809          <span class="ep-desc">Save or update the retention policy</span>
31810          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31811        </div>
31812        <div class="ep-body">
31813          <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>
31814          <p class="params-heading">Request Body (application/json)</p>
31815          <table class="params">
31816            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31817            <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>
31818            <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>
31819            <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>
31820            <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>
31821          </table>
31822          <details class="schema"><summary>Response</summary>
31823<div class="schema-block">204 No Content — policy saved and task (re)started
31824
31825500 Internal Server Error — { "error": string }</div></details>
31826          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31827          <div class="curl-wrap">
31828            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31829  -H "Authorization: Bearer $SLOC_API_KEY" \
31830  -H "Content-Type: application/json" \
31831  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31832  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31833            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31834          </div>
31835        </div>
31836      </div>
31837
31838      <div class="ep-card">
31839        <div class="ep-header">
31840          <span class="method post">POST</span>
31841          <span class="ep-path">/api/cleanup-policy/run-now</span>
31842          <span class="auth-badge protected">Protected</span>
31843          <span class="ep-desc">Trigger an immediate cleanup pass</span>
31844          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31845        </div>
31846        <div class="ep-body">
31847          <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>
31848          <details class="schema"><summary>Response schema</summary>
31849<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
31850          <p class="curl-heading">Example</p>
31851          <div class="curl-wrap">
31852            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31853  -H "Authorization: Bearer $SLOC_API_KEY" \
31854  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31855            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31856          </div>
31857        </div>
31858      </div>
31859
31860      <div class="ep-card">
31861        <div class="ep-header">
31862          <span class="method delete">DELETE</span>
31863          <span class="ep-path">/api/cleanup-policy</span>
31864          <span class="auth-badge protected">Protected</span>
31865          <span class="ep-desc">Remove the retention policy and stop the background task</span>
31866          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31867        </div>
31868        <div class="ep-body">
31869          <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>
31870          <details class="schema"><summary>Response</summary>
31871<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31872          <p class="curl-heading">Example</p>
31873          <div class="curl-wrap">
31874            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31875  -H "Authorization: Bearer $SLOC_API_KEY" \
31876  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31877            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31878          </div>
31879        </div>
31880      </div>
31881    </div>
31882
31883    <!-- Scan Profiles -->
31884    <div class="section">
31885      <h2 class="section-title">Scan Profiles</h2>
31886
31887      <div class="ep-card">
31888        <div class="ep-header">
31889          <span class="method get">GET</span>
31890          <span class="ep-path">/api/scan-profiles</span>
31891          <span class="auth-badge protected">Protected</span>
31892          <span class="ep-desc">List saved scan profiles</span>
31893          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31894        </div>
31895        <div class="ep-body">
31896          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31897          <details class="schema"><summary>Response schema</summary>
31898<div class="schema-block">{
31899  "profiles": [{
31900    "id":         string,   // UUID
31901    "name":       string,
31902    "created_at": string,   // ISO-8601
31903    "params":     object
31904  }]
31905}</div></details>
31906          <p class="curl-heading">Example</p>
31907          <div class="curl-wrap">
31908            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31909  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31910            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31911          </div>
31912        </div>
31913      </div>
31914
31915      <div class="ep-card">
31916        <div class="ep-header">
31917          <span class="method post">POST</span>
31918          <span class="ep-path">/api/scan-profiles</span>
31919          <span class="auth-badge protected">Protected</span>
31920          <span class="ep-desc">Save a scan profile</span>
31921          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31922        </div>
31923        <div class="ep-body">
31924          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31925          <p class="params-heading">Request Body (application/json)</p>
31926          <table class="params">
31927            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31928            <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>
31929            <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>
31930          </table>
31931          <details class="schema"><summary>Response schema</summary>
31932<div class="schema-block">{ "ok": true }</div></details>
31933          <p class="curl-heading">Example</p>
31934          <div class="curl-wrap">
31935            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31936  -H "Authorization: Bearer $SLOC_API_KEY" \
31937  -H "Content-Type: application/json" \
31938  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31939  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31940            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31941          </div>
31942        </div>
31943      </div>
31944
31945      <div class="ep-card">
31946        <div class="ep-header">
31947          <span class="method delete">DELETE</span>
31948          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31949          <span class="auth-badge protected">Protected</span>
31950          <span class="ep-desc">Delete a scan profile</span>
31951          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31952        </div>
31953        <div class="ep-body">
31954          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31955          <p class="params-heading">Path Parameters</p>
31956          <table class="params">
31957            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31958            <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>
31959          </table>
31960          <details class="schema"><summary>Response schema</summary>
31961<div class="schema-block">{ "ok": true }</div></details>
31962          <p class="curl-heading">Example</p>
31963          <div class="curl-wrap">
31964            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31965  -H "Authorization: Bearer $SLOC_API_KEY" \
31966  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
31967            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31968          </div>
31969        </div>
31970      </div>
31971    </div>
31972
31973    <!-- Scheduled Scans -->
31974    <div class="section">
31975      <h2 class="section-title">Scheduled Scans</h2>
31976
31977      <div class="ep-card">
31978        <div class="ep-header">
31979          <span class="method get">GET</span>
31980          <span class="ep-path">/api/schedules</span>
31981          <span class="auth-badge protected">Protected</span>
31982          <span class="ep-desc">List configured schedules</span>
31983          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31984        </div>
31985        <div class="ep-body">
31986          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31987          <p class="curl-heading">Example</p>
31988          <div class="curl-wrap">
31989            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31990  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31991            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31992          </div>
31993        </div>
31994      </div>
31995
31996      <div class="ep-card">
31997        <div class="ep-header">
31998          <span class="method post">POST</span>
31999          <span class="ep-path">/api/schedules</span>
32000          <span class="auth-badge protected">Protected</span>
32001          <span class="ep-desc">Create a schedule</span>
32002          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32003        </div>
32004        <div class="ep-body">
32005          <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>
32006          <p class="curl-heading">Example</p>
32007          <div class="curl-wrap">
32008            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32009  -H "Authorization: Bearer $SLOC_API_KEY" \
32010  -H "Content-Type: application/json" \
32011  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32012  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32013            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32014          </div>
32015        </div>
32016      </div>
32017
32018      <div class="ep-card">
32019        <div class="ep-header">
32020          <span class="method delete">DELETE</span>
32021          <span class="ep-path">/api/schedules</span>
32022          <span class="auth-badge protected">Protected</span>
32023          <span class="ep-desc">Delete a schedule</span>
32024          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32025        </div>
32026        <div class="ep-body">
32027          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32028          <p class="curl-heading">Example</p>
32029          <div class="curl-wrap">
32030            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32031  -H "Authorization: Bearer $SLOC_API_KEY" \
32032  -H "Content-Type: application/json" \
32033  -d '{"id":"&lt;schedule_id&gt;"}' \
32034  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32035            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32036          </div>
32037        </div>
32038      </div>
32039    </div>
32040
32041    <!-- Git Browser -->
32042    <div class="section">
32043      <h2 class="section-title">Git Browser</h2>
32044
32045      <div class="ep-card">
32046        <div class="ep-header">
32047          <span class="method get">GET</span>
32048          <span class="ep-path">/api/git/refs</span>
32049          <span class="auth-badge protected">Protected</span>
32050          <span class="ep-desc">List git refs for a repository</span>
32051          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32052        </div>
32053        <div class="ep-body">
32054          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32055          <p class="params-heading">Query Parameters</p>
32056          <table class="params">
32057            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32058            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32059          </table>
32060          <p class="curl-heading">Example</p>
32061          <div class="curl-wrap">
32062            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32063  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
32064            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32065          </div>
32066        </div>
32067      </div>
32068
32069      <div class="ep-card">
32070        <div class="ep-header">
32071          <span class="method get">GET</span>
32072          <span class="ep-path">/api/git/scan-ref</span>
32073          <span class="auth-badge protected">Protected</span>
32074          <span class="ep-desc">SLOC-scan a specific git ref</span>
32075          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32076        </div>
32077        <div class="ep-body">
32078          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32079          <p class="params-heading">Query Parameters</p>
32080          <table class="params">
32081            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32082            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32083            <tr><td class="pt-name">ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
32084          </table>
32085          <p class="curl-heading">Example</p>
32086          <div class="curl-wrap">
32087            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32088  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
32089            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32090          </div>
32091        </div>
32092      </div>
32093
32094      <div class="ep-card">
32095        <div class="ep-header">
32096          <span class="method get">GET</span>
32097          <span class="ep-path">/api/git/compare-refs</span>
32098          <span class="auth-badge protected">Protected</span>
32099          <span class="ep-desc">Compare SLOC across two git refs</span>
32100          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32101        </div>
32102        <div class="ep-body">
32103          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32104          <p class="params-heading">Query Parameters</p>
32105          <table class="params">
32106            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32107            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32108            <tr><td class="pt-name">base</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
32109            <tr><td class="pt-name">head</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
32110          </table>
32111          <p class="curl-heading">Example</p>
32112          <div class="curl-wrap">
32113            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32114  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?path=/path/to/repo&amp;base=v1.0&amp;head=main"</pre>
32115            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32116          </div>
32117        </div>
32118      </div>
32119    </div>
32120
32121    <!-- Webhooks -->
32122    <div class="section">
32123      <h2 class="section-title">Webhooks</h2>
32124      <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>
32125
32126      <div class="ep-card">
32127        <div class="ep-header">
32128          <span class="method post">POST</span>
32129          <span class="ep-path">/webhooks/github</span>
32130          <span class="auth-badge hmac">HMAC</span>
32131          <span class="ep-desc">GitHub push event receiver</span>
32132          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32133        </div>
32134        <div class="ep-body">
32135          <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>
32136          <p class="params-heading">Required Headers</p>
32137          <table class="params">
32138            <tr><th>Header</th><th>Value</th></tr>
32139            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32140            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32141            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32142          </table>
32143        </div>
32144      </div>
32145
32146      <div class="ep-card">
32147        <div class="ep-header">
32148          <span class="method post">POST</span>
32149          <span class="ep-path">/webhooks/gitlab</span>
32150          <span class="auth-badge hmac">HMAC</span>
32151          <span class="ep-desc">GitLab push event receiver</span>
32152          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32153        </div>
32154        <div class="ep-body">
32155          <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>
32156          <p class="params-heading">Required Headers</p>
32157          <table class="params">
32158            <tr><th>Header</th><th>Value</th></tr>
32159            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32160            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32161            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32162          </table>
32163        </div>
32164      </div>
32165
32166      <div class="ep-card">
32167        <div class="ep-header">
32168          <span class="method post">POST</span>
32169          <span class="ep-path">/webhooks/bitbucket</span>
32170          <span class="auth-badge hmac">HMAC</span>
32171          <span class="ep-desc">Bitbucket push event receiver</span>
32172          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32173        </div>
32174        <div class="ep-body">
32175          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32176          <p class="params-heading">Required Headers</p>
32177          <table class="params">
32178            <tr><th>Header</th><th>Value</th></tr>
32179            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32180            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32181          </table>
32182        </div>
32183      </div>
32184    </div>
32185
32186    <!-- Config -->
32187    <div class="section">
32188      <h2 class="section-title">Config Import / Export</h2>
32189
32190      <div class="ep-card">
32191        <div class="ep-header">
32192          <span class="method get">GET</span>
32193          <span class="ep-path">/export-config</span>
32194          <span class="auth-badge protected">Protected</span>
32195          <span class="ep-desc">Export server configuration as JSON</span>
32196          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32197        </div>
32198        <div class="ep-body">
32199          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32200          <p class="curl-heading">Example</p>
32201          <div class="curl-wrap">
32202            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32203  -o config.json \
32204  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32205            <button class="curl-copy-btn" data-target="c-export">Copy</button>
32206          </div>
32207        </div>
32208      </div>
32209
32210      <div class="ep-card">
32211        <div class="ep-header">
32212          <span class="method post">POST</span>
32213          <span class="ep-path">/import-config</span>
32214          <span class="auth-badge protected">Protected</span>
32215          <span class="ep-desc">Import server configuration</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">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32220          <p class="curl-heading">Example</p>
32221          <div class="curl-wrap">
32222            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32223  -H "Authorization: Bearer $SLOC_API_KEY" \
32224  -H "Content-Type: application/json" \
32225  -d @config.json \
32226  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32227            <button class="curl-copy-btn" data-target="c-import">Copy</button>
32228          </div>
32229        </div>
32230      </div>
32231    </div>
32232
32233    <!-- CI Ingest -->
32234    <div class="section">
32235      <h2 class="section-title">CI Ingest</h2>
32236
32237      <div class="ep-card">
32238        <div class="ep-header">
32239          <span class="method post">POST</span>
32240          <span class="ep-path">/api/ingest</span>
32241          <span class="auth-badge protected">Protected</span>
32242          <span class="ep-desc">Push a pre-computed scan result from CI</span>
32243          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32244        </div>
32245        <div class="ep-body">
32246          <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>
32247          <p class="params-heading">Query Parameters</p>
32248          <table class="params">
32249            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32250            <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>
32251          </table>
32252          <p class="params-heading">Request Body (application/json)</p>
32253          <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>
32254          <details class="schema"><summary>Response schema</summary>
32255<div class="schema-block">// 201 Created
32256{
32257  "run_id":   string,  // UUID of the ingested run
32258  "view_url": string   // relative URL to the report page
32259}</div></details>
32260          <p class="curl-heading">Example</p>
32261          <div class="curl-wrap">
32262            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32263  -H "Authorization: Bearer $SLOC_API_KEY" \
32264  -H "Content-Type: application/json" \
32265  -d @result.json \
32266  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32267            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32268          </div>
32269        </div>
32270      </div>
32271    </div>
32272
32273    <!-- Artifact Download -->
32274    <div class="section">
32275      <h2 class="section-title">Artifact Download</h2>
32276
32277      <div class="ep-card">
32278        <div class="ep-header">
32279          <span class="method get">GET</span>
32280          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32281          <span class="auth-badge protected">Protected</span>
32282          <span class="ep-desc">Download or view a scan artifact</span>
32283          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32284        </div>
32285        <div class="ep-body">
32286          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32287          <p class="params-heading">Path Parameters</p>
32288          <table class="params">
32289            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32290            <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>
32291            <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>
32292          </table>
32293          <p class="params-heading">Query Parameters</p>
32294          <table class="params">
32295            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32296            <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>
32297          </table>
32298          <p class="curl-heading">Example — download JSON result</p>
32299          <div class="curl-wrap">
32300            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32301  -o result.json \
32302  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
32303            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32304          </div>
32305        </div>
32306      </div>
32307    </div>
32308
32309    <!-- Embed Widget -->
32310    <div class="section">
32311      <h2 class="section-title">Embed Widget</h2>
32312
32313      <div class="ep-card">
32314        <div class="ep-header">
32315          <span class="method get">GET</span>
32316          <span class="ep-path">/embed/summary</span>
32317          <span class="auth-badge protected">Protected</span>
32318          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32319          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32320        </div>
32321        <div class="ep-body">
32322          <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>
32323          <p class="params-heading">Query Parameters</p>
32324          <table class="params">
32325            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32326            <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>
32327            <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>
32328          </table>
32329          <p class="curl-heading">Example</p>
32330          <div class="curl-wrap">
32331            <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"
32332        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
32333            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32334          </div>
32335        </div>
32336      </div>
32337    </div>
32338
32339    <!-- Confluence Integration -->
32340    <div class="section">
32341      <h2 class="section-title">Confluence Integration</h2>
32342
32343      <div class="ep-card">
32344        <div class="ep-header">
32345          <span class="method get">GET</span>
32346          <span class="ep-path">/api/confluence/config</span>
32347          <span class="auth-badge protected">Protected</span>
32348          <span class="ep-desc">Get current Confluence configuration</span>
32349          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32350        </div>
32351        <div class="ep-body">
32352          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32353          <details class="schema"><summary>Response schema</summary>
32354<div class="schema-block">{
32355  "configured":     boolean,
32356  "tier":           "cloud" | "server",
32357  "base_url":       string,
32358  "username":       string,
32359  "api_token_set":  boolean,
32360  "space_key":      string,
32361  "parent_page_id": string | null,
32362  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
32363}</div></details>
32364          <p class="curl-heading">Example</p>
32365          <div class="curl-wrap">
32366            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32367  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32368            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32369          </div>
32370        </div>
32371      </div>
32372
32373      <div class="ep-card">
32374        <div class="ep-header">
32375          <span class="method post">POST</span>
32376          <span class="ep-path">/api/confluence/config</span>
32377          <span class="auth-badge protected">Protected</span>
32378          <span class="ep-desc">Save Confluence configuration</span>
32379          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32380        </div>
32381        <div class="ep-body">
32382          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32383          <p class="params-heading">Request Body (application/json)</p>
32384          <table class="params">
32385            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32386            <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>
32387            <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>
32388            <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>
32389            <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>
32390            <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>
32391            <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>
32392            <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>
32393          </table>
32394          <details class="schema"><summary>Response schema</summary>
32395<div class="schema-block">{ "ok": true }</div></details>
32396          <p class="curl-heading">Example</p>
32397          <div class="curl-wrap">
32398            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32399  -H "Authorization: Bearer $SLOC_API_KEY" \
32400  -H "Content-Type: application/json" \
32401  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32402  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32403            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32404          </div>
32405        </div>
32406      </div>
32407
32408      <div class="ep-card">
32409        <div class="ep-header">
32410          <span class="method post">POST</span>
32411          <span class="ep-path">/api/confluence/test</span>
32412          <span class="auth-badge protected">Protected</span>
32413          <span class="ep-desc">Test Confluence connection</span>
32414          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32415        </div>
32416        <div class="ep-body">
32417          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32418          <details class="schema"><summary>Response schema</summary>
32419<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32420          <p class="curl-heading">Example</p>
32421          <div class="curl-wrap">
32422            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32423  -H "Authorization: Bearer $SLOC_API_KEY" \
32424  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32425            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32426          </div>
32427        </div>
32428      </div>
32429
32430      <div class="ep-card">
32431        <div class="ep-header">
32432          <span class="method post">POST</span>
32433          <span class="ep-path">/api/confluence/post</span>
32434          <span class="auth-badge protected">Protected</span>
32435          <span class="ep-desc">Publish a scan report to Confluence</span>
32436          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32437        </div>
32438        <div class="ep-body">
32439          <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>
32440          <p class="params-heading">Request Body (application/json)</p>
32441          <table class="params">
32442            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32443            <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>
32444            <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>
32445            <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>
32446          </table>
32447          <details class="schema"><summary>Response schema</summary>
32448<div class="schema-block">// 200 OK
32449{ "ok": true, "page_id": string }
32450
32451// 400 / 502 on error
32452{ "ok": false, "error": string }</div></details>
32453          <p class="curl-heading">Example</p>
32454          <div class="curl-wrap">
32455            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32456  -H "Authorization: Bearer $SLOC_API_KEY" \
32457  -H "Content-Type: application/json" \
32458  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
32459  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32460            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32461          </div>
32462        </div>
32463      </div>
32464
32465      <div class="ep-card">
32466        <div class="ep-header">
32467          <span class="method get">GET</span>
32468          <span class="ep-path">/api/confluence/wiki-markup</span>
32469          <span class="auth-badge protected">Protected</span>
32470          <span class="ep-desc">Get Confluence wiki markup for a run</span>
32471          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32472        </div>
32473        <div class="ep-body">
32474          <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>
32475          <p class="params-heading">Query Parameters</p>
32476          <table class="params">
32477            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32478            <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>
32479          </table>
32480          <p class="curl-heading">Example</p>
32481          <div class="curl-wrap">
32482            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32483  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
32484            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32485          </div>
32486        </div>
32487      </div>
32488    </div>
32489
32490    <!-- Authentication -->
32491    <div class="section">
32492      <h2 class="section-title">Authentication</h2>
32493      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32494
32495      <div class="ep-card">
32496        <div class="ep-header">
32497          <span class="method get">GET</span>
32498          <span class="ep-path">/auth/login</span>
32499          <span class="auth-badge public">Public</span>
32500          <span class="ep-desc">Login page</span>
32501          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32502        </div>
32503        <div class="ep-body">
32504          <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>
32505          <p class="params-heading">Query Parameters</p>
32506          <table class="params">
32507            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32508            <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>
32509            <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>
32510          </table>
32511        </div>
32512      </div>
32513
32514      <div class="ep-card">
32515        <div class="ep-header">
32516          <span class="method post">POST</span>
32517          <span class="ep-path">/auth/login</span>
32518          <span class="auth-badge public">Public</span>
32519          <span class="ep-desc">Submit credentials and get a session cookie</span>
32520          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32521        </div>
32522        <div class="ep-body">
32523          <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>
32524          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32525          <table class="params">
32526            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32527            <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>
32528            <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>
32529          </table>
32530          <p class="curl-heading">Example</p>
32531          <div class="curl-wrap">
32532            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32533  -d "key=$SLOC_API_KEY&amp;next=/" \
32534  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32535            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32536          </div>
32537        </div>
32538      </div>
32539    </div>
32540
32541    <!-- Coverage Suggestion -->
32542    <div class="section">
32543      <h2 class="section-title">Coverage Suggestion</h2>
32544
32545      <div class="ep-card">
32546        <div class="ep-header">
32547          <span class="method get">GET</span>
32548          <span class="ep-path">/api/suggest-coverage</span>
32549          <span class="auth-badge protected">Protected</span>
32550          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32551          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32552        </div>
32553        <div class="ep-body">
32554          <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>
32555          <p class="params-heading">Query Parameters</p>
32556          <table class="params">
32557            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32558            <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>
32559          </table>
32560          <details class="schema"><summary>Response schema</summary>
32561<div class="schema-block">{
32562  "found": string | null,  // absolute path to the coverage file, if detected
32563  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32564  "hint":  string | null   // shell command to generate coverage if not found
32565}</div></details>
32566          <p class="curl-heading">Example</p>
32567          <div class="curl-wrap">
32568            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32569  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32570            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32571          </div>
32572        </div>
32573      </div>
32574    </div>
32575
32576  </div>
32577
32578  <footer class="site-footer">
32579    local code analysis - metrics, history and reports
32580    &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>
32581    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32582    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32583    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32584    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
32585  </footer>
32586
32587  <script nonce="{{ csp_nonce }}">
32588    (function () {
32589      var base = window.location.origin;
32590      document.getElementById('base-url').textContent = base;
32591      document.querySelectorAll('.base-url-slot').forEach(function (el) {
32592        el.textContent = base;
32593      });
32594
32595      document.querySelectorAll('.ep-header').forEach(function (hdr) {
32596        hdr.addEventListener('click', function () {
32597          hdr.closest('.ep-card').classList.toggle('open');
32598        });
32599      });
32600
32601      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32602        btn.addEventListener('click', function () {
32603          var targetId = btn.dataset.target;
32604          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32605          if (!pre) return;
32606          navigator.clipboard.writeText(pre.textContent).then(function () {
32607            btn.textContent = 'Copied!';
32608            btn.classList.add('copied');
32609            setTimeout(function () {
32610              btn.textContent = 'Copy';
32611              btn.classList.remove('copied');
32612            }, 2000);
32613          });
32614        });
32615      });
32616
32617      var storageKey = 'oxide-sloc-theme';
32618      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32619      var themeBtn = document.getElementById('theme-toggle');
32620      if (themeBtn) {
32621        themeBtn.addEventListener('click', function () {
32622          var dark = document.body.classList.toggle('dark-theme');
32623          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32624        });
32625      }
32626      (function() {
32627        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'}];
32628        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);});}
32629        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32630        var btn=document.getElementById('settings-btn');if(!btn)return;
32631        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32632        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>';
32633        document.body.appendChild(m);
32634        var g=document.getElementById('scheme-grid');
32635        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);});
32636        var cl=document.getElementById('settings-close');
32637        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);});})();
32638        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');});
32639        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32640        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32641      })();
32642      (function randomizeWatermarks() {
32643        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32644        if (!wms.length) return;
32645        var placed = [];
32646        function tooClose(top, left) {
32647          for (var i = 0; i < placed.length; i++) {
32648            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32649            if (dt < 16 && dl < 12) return true;
32650          }
32651          return false;
32652        }
32653        function pick(leftBand) {
32654          for (var attempt = 0; attempt < 50; attempt++) {
32655            var top = Math.random() * 88 + 2;
32656            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32657            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32658          }
32659          var top = Math.random() * 88 + 2;
32660          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32661          placed.push([top, left]); return [top, left];
32662        }
32663        var half = Math.floor(wms.length / 2);
32664        wms.forEach(function (img, i) {
32665          var pos = pick(i < half);
32666          var size = Math.floor(Math.random() * 100 + 120);
32667          var rot = (Math.random() * 360).toFixed(1);
32668          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32669          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;
32670        });
32671      })();
32672      (function spawnCodeParticles() {
32673        var container = document.getElementById('code-particles');
32674        if (!container) return;
32675        var snippets = [
32676          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32677          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32678          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32679          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32680          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32681        ];
32682        var count = 38;
32683        for (var i = 0; i < count; i++) {
32684          (function(idx) {
32685            var el = document.createElement('span');
32686            el.className = 'code-particle';
32687            el.textContent = snippets[idx % snippets.length];
32688            var left = Math.random() * 94 + 2;
32689            var top = Math.random() * 88 + 6;
32690            var dur = (Math.random() * 10 + 9).toFixed(1);
32691            var delay = (Math.random() * 18).toFixed(1);
32692            var rot = (Math.random() * 26 - 13).toFixed(1);
32693            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32694            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32695            container.appendChild(el);
32696          })(i);
32697        }
32698      })();
32699    }());
32700  </script>
32701</body>
32702</html>
32703"##,
32704    ext = "html"
32705)]
32706struct ApiDocsTemplate {
32707    has_api_key: bool,
32708    csp_nonce: String,
32709    version: &'static str,
32710}
32711
32712#[cfg(test)]
32713mod form_config_tests {
32714    use super::*;
32715    use sloc_config::{
32716        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32717    };
32718
32719    fn blank_form() -> AnalyzeForm {
32720        AnalyzeForm {
32721            path: ".".to_string(),
32722            git_repo: None,
32723            git_ref: None,
32724            mixed_line_policy: None,
32725            python_docstrings_as_comments: None,
32726            generated_file_detection: None,
32727            minified_file_detection: None,
32728            vendor_directory_detection: None,
32729            include_lockfiles: None,
32730            binary_file_behavior: None,
32731            output_dir: None,
32732            report_title: None,
32733            report_header_footer: None,
32734            include_globs: None,
32735            exclude_globs: None,
32736            submodule_breakdown: None,
32737            coverage_file: None,
32738            continuation_line_policy: None,
32739            blank_in_block_comment_policy: None,
32740            count_compiler_directives: None,
32741            style_col_threshold: None,
32742            style_analysis_enabled: None,
32743            style_score_threshold: None,
32744            style_lang_scope: None,
32745            cocomo_mode: None,
32746            complexity_alert: None,
32747            exclude_duplicates: None,
32748            activity_window: None,
32749        }
32750    }
32751
32752    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32753        let mut cfg = sloc_config::AppConfig::default();
32754        apply_form_to_config(&mut cfg, form);
32755        cfg
32756    }
32757
32758    // ── activity_window (git hotspots — on by default) ──
32759
32760    #[test]
32761    fn extract_long_commit_picks_super_repo_by_short_prefix() {
32762        // A pretty-printed JSON tail containing several submodule git_commit_long
32763        // values plus the super-repo's; the helper must return the one whose hash
32764        // starts with the known short SHA, ignoring the others and any null value.
32765        let dir = tempfile::tempdir().unwrap();
32766        let path = dir.path().join("result.json");
32767        let body = r#"{
32768  "submodules": [
32769    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32770    { "git_commit_long": null }
32771  ],
32772  "git_commit_short": "4c2cd9b",
32773  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32774}"#;
32775        std::fs::write(&path, body).unwrap();
32776        assert_eq!(
32777            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32778            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32779        );
32780        // No match for an unrelated short SHA, and empty short yields None.
32781        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32782        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32783    }
32784
32785    #[test]
32786    fn activity_window_defaults_on_when_field_blank() {
32787        // Blank form field keeps the config default (90 days).
32788        let cfg = apply(&blank_form());
32789        assert_eq!(cfg.analysis.activity_window_days, Some(90));
32790    }
32791
32792    #[test]
32793    fn activity_window_override_sets_days() {
32794        let mut form = blank_form();
32795        form.activity_window = Some("30".to_string());
32796        let cfg = apply(&form);
32797        assert_eq!(cfg.analysis.activity_window_days, Some(30));
32798    }
32799
32800    #[test]
32801    fn activity_window_zero_disables() {
32802        // An explicit 0 from the form disables hotspots (overrides the default-on).
32803        let mut form = blank_form();
32804        form.activity_window = Some("0".to_string());
32805        let cfg = apply(&form);
32806        assert_eq!(cfg.analysis.activity_window_days, Some(0));
32807    }
32808
32809    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
32810
32811    #[test]
32812    fn python_docstrings_false_when_unchecked() {
32813        // Checkbox absent in form data (unchecked) → field must be false.
32814        let cfg = apply(&blank_form());
32815        assert!(
32816            !cfg.analysis.python_docstrings_as_comments,
32817            "absent python_docstrings_as_comments must map to false"
32818        );
32819    }
32820
32821    #[test]
32822    fn python_docstrings_true_when_checked() {
32823        // Browser sends "on" (no value= attr on the checkbox).
32824        let mut form = blank_form();
32825        form.python_docstrings_as_comments = Some("on".to_string());
32826        let cfg = apply(&form);
32827        assert!(cfg.analysis.python_docstrings_as_comments);
32828    }
32829
32830    #[test]
32831    fn python_docstrings_true_for_any_non_none_value() {
32832        // The handler uses .is_some() — any non-None value means "checked".
32833        let mut form = blank_form();
32834        form.python_docstrings_as_comments = Some("true".to_string());
32835        assert!(apply(&form).analysis.python_docstrings_as_comments);
32836    }
32837
32838    // ── submodule_breakdown (checkbox with value="enabled") ──
32839
32840    #[test]
32841    fn submodule_breakdown_false_when_unchecked() {
32842        let cfg = apply(&blank_form());
32843        assert!(
32844            !cfg.discovery.submodule_breakdown,
32845            "absent submodule_breakdown must map to false"
32846        );
32847    }
32848
32849    #[test]
32850    fn submodule_breakdown_true_when_value_enabled() {
32851        let mut form = blank_form();
32852        form.submodule_breakdown = Some("enabled".to_string());
32853        assert!(apply(&form).discovery.submodule_breakdown);
32854    }
32855
32856    #[test]
32857    fn submodule_breakdown_false_for_wrong_value() {
32858        // If somehow a value other than "enabled" is sent, it must still be false.
32859        let mut form = blank_form();
32860        form.submodule_breakdown = Some("on".to_string());
32861        assert!(
32862            !apply(&form).discovery.submodule_breakdown,
32863            "submodule_breakdown only becomes true for the exact value 'enabled'"
32864        );
32865    }
32866
32867    // ── generated_file_detection (select: "enabled" | "disabled") ──
32868
32869    #[test]
32870    fn generated_detection_true_when_enabled() {
32871        let mut form = blank_form();
32872        form.generated_file_detection = Some("enabled".to_string());
32873        assert!(apply(&form).analysis.generated_file_detection);
32874    }
32875
32876    #[test]
32877    fn generated_detection_false_when_disabled() {
32878        let mut form = blank_form();
32879        form.generated_file_detection = Some("disabled".to_string());
32880        assert!(!apply(&form).analysis.generated_file_detection);
32881    }
32882
32883    #[test]
32884    fn generated_detection_true_when_absent() {
32885        // None != Some("disabled") → true (safe default)
32886        assert!(
32887            apply(&blank_form()).analysis.generated_file_detection,
32888            "absent field must default to true (detection on)"
32889        );
32890    }
32891
32892    // ── minified_file_detection ──
32893
32894    #[test]
32895    fn minified_detection_false_when_disabled() {
32896        let mut form = blank_form();
32897        form.minified_file_detection = Some("disabled".to_string());
32898        assert!(!apply(&form).analysis.minified_file_detection);
32899    }
32900
32901    #[test]
32902    fn minified_detection_true_when_enabled() {
32903        let mut form = blank_form();
32904        form.minified_file_detection = Some("enabled".to_string());
32905        assert!(apply(&form).analysis.minified_file_detection);
32906    }
32907
32908    #[test]
32909    fn minified_detection_true_when_absent() {
32910        assert!(apply(&blank_form()).analysis.minified_file_detection);
32911    }
32912
32913    // ── vendor_directory_detection ──
32914
32915    #[test]
32916    fn vendor_detection_false_when_disabled() {
32917        let mut form = blank_form();
32918        form.vendor_directory_detection = Some("disabled".to_string());
32919        assert!(!apply(&form).analysis.vendor_directory_detection);
32920    }
32921
32922    #[test]
32923    fn vendor_detection_true_when_enabled() {
32924        let mut form = blank_form();
32925        form.vendor_directory_detection = Some("enabled".to_string());
32926        assert!(apply(&form).analysis.vendor_directory_detection);
32927    }
32928
32929    #[test]
32930    fn vendor_detection_true_when_absent() {
32931        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32932    }
32933
32934    // ── include_lockfiles (select: "disabled" default | "enabled") ──
32935
32936    #[test]
32937    fn lockfiles_false_when_absent() {
32938        // None == Some("enabled") is false → lockfiles off (correct safe default)
32939        assert!(!apply(&blank_form()).analysis.include_lockfiles);
32940    }
32941
32942    #[test]
32943    fn lockfiles_false_when_disabled() {
32944        let mut form = blank_form();
32945        form.include_lockfiles = Some("disabled".to_string());
32946        assert!(!apply(&form).analysis.include_lockfiles);
32947    }
32948
32949    #[test]
32950    fn lockfiles_true_when_enabled() {
32951        let mut form = blank_form();
32952        form.include_lockfiles = Some("enabled".to_string());
32953        assert!(apply(&form).analysis.include_lockfiles);
32954    }
32955
32956    // ── count_compiler_directives ──
32957
32958    #[test]
32959    fn compiler_directives_true_when_absent() {
32960        assert!(
32961            apply(&blank_form()).analysis.count_compiler_directives,
32962            "absent count_compiler_directives must default to true"
32963        );
32964    }
32965
32966    #[test]
32967    fn compiler_directives_true_when_enabled() {
32968        let mut form = blank_form();
32969        form.count_compiler_directives = Some("enabled".to_string());
32970        assert!(apply(&form).analysis.count_compiler_directives);
32971    }
32972
32973    #[test]
32974    fn compiler_directives_false_when_disabled() {
32975        let mut form = blank_form();
32976        form.count_compiler_directives = Some("disabled".to_string());
32977        assert!(!apply(&form).analysis.count_compiler_directives);
32978    }
32979
32980    // ── mixed_line_policy (enum select) ──
32981
32982    #[test]
32983    fn mixed_policy_unchanged_when_absent() {
32984        // None → if-let does nothing → stays at config default (CodeOnly)
32985        assert_eq!(
32986            apply(&blank_form()).analysis.mixed_line_policy,
32987            MixedLinePolicy::CodeOnly
32988        );
32989    }
32990
32991    #[test]
32992    fn mixed_policy_code_only() {
32993        let mut form = blank_form();
32994        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32995        assert_eq!(
32996            apply(&form).analysis.mixed_line_policy,
32997            MixedLinePolicy::CodeOnly
32998        );
32999    }
33000
33001    #[test]
33002    fn mixed_policy_code_and_comment() {
33003        let mut form = blank_form();
33004        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33005        assert_eq!(
33006            apply(&form).analysis.mixed_line_policy,
33007            MixedLinePolicy::CodeAndComment
33008        );
33009    }
33010
33011    #[test]
33012    fn mixed_policy_comment_only() {
33013        let mut form = blank_form();
33014        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33015        assert_eq!(
33016            apply(&form).analysis.mixed_line_policy,
33017            MixedLinePolicy::CommentOnly
33018        );
33019    }
33020
33021    #[test]
33022    fn mixed_policy_separate_mixed_category() {
33023        let mut form = blank_form();
33024        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33025        assert_eq!(
33026            apply(&form).analysis.mixed_line_policy,
33027            MixedLinePolicy::SeparateMixedCategory
33028        );
33029    }
33030
33031    // ── binary_file_behavior (enum select) ──
33032
33033    #[test]
33034    fn binary_behavior_skip_when_absent() {
33035        assert_eq!(
33036            apply(&blank_form()).analysis.binary_file_behavior,
33037            BinaryFileBehavior::Skip
33038        );
33039    }
33040
33041    #[test]
33042    fn binary_behavior_skip() {
33043        let mut form = blank_form();
33044        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33045        assert_eq!(
33046            apply(&form).analysis.binary_file_behavior,
33047            BinaryFileBehavior::Skip
33048        );
33049    }
33050
33051    #[test]
33052    fn binary_behavior_fail() {
33053        let mut form = blank_form();
33054        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33055        assert_eq!(
33056            apply(&form).analysis.binary_file_behavior,
33057            BinaryFileBehavior::Fail
33058        );
33059    }
33060
33061    // ── continuation_line_policy (enum select) ──
33062
33063    #[test]
33064    fn continuation_policy_each_physical_when_absent() {
33065        assert_eq!(
33066            apply(&blank_form()).analysis.continuation_line_policy,
33067            ContinuationLinePolicy::EachPhysicalLine
33068        );
33069    }
33070
33071    #[test]
33072    fn continuation_policy_collapse_to_logical() {
33073        let mut form = blank_form();
33074        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33075        assert_eq!(
33076            apply(&form).analysis.continuation_line_policy,
33077            ContinuationLinePolicy::CollapseToLogical
33078        );
33079    }
33080
33081    // ── blank_in_block_comment_policy (enum select) ──
33082
33083    #[test]
33084    fn blank_in_block_comment_count_as_comment_when_absent() {
33085        assert_eq!(
33086            apply(&blank_form()).analysis.blank_in_block_comment_policy,
33087            BlankInBlockCommentPolicy::CountAsComment
33088        );
33089    }
33090
33091    #[test]
33092    fn blank_in_block_comment_count_as_blank() {
33093        let mut form = blank_form();
33094        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33095        assert_eq!(
33096            apply(&form).analysis.blank_in_block_comment_policy,
33097            BlankInBlockCommentPolicy::CountAsBlank
33098        );
33099    }
33100
33101    // ── style_col_threshold ──
33102
33103    #[test]
33104    fn style_threshold_80() {
33105        let mut form = blank_form();
33106        form.style_col_threshold = Some("80".to_string());
33107        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33108    }
33109
33110    #[test]
33111    fn style_threshold_100() {
33112        let mut form = blank_form();
33113        form.style_col_threshold = Some("100".to_string());
33114        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33115    }
33116
33117    #[test]
33118    fn style_threshold_120() {
33119        let mut form = blank_form();
33120        form.style_col_threshold = Some("120".to_string());
33121        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33122    }
33123
33124    #[test]
33125    fn style_threshold_invalid_value_leaves_default() {
33126        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
33127        let mut cfg = sloc_config::AppConfig::default();
33128        let mut form = blank_form();
33129        form.style_col_threshold = Some("42".to_string());
33130        apply_form_to_config(&mut cfg, &form);
33131        assert_eq!(
33132            cfg.analysis.style_col_threshold, 80,
33133            "invalid threshold must not change config"
33134        );
33135    }
33136
33137    #[test]
33138    fn style_threshold_non_numeric_leaves_default() {
33139        let mut cfg = sloc_config::AppConfig::default();
33140        let mut form = blank_form();
33141        form.style_col_threshold = Some("large".to_string());
33142        apply_form_to_config(&mut cfg, &form);
33143        assert_eq!(cfg.analysis.style_col_threshold, 80);
33144    }
33145
33146    #[test]
33147    fn style_threshold_zero_leaves_default() {
33148        let mut cfg = sloc_config::AppConfig::default();
33149        let mut form = blank_form();
33150        form.style_col_threshold = Some("0".to_string());
33151        apply_form_to_config(&mut cfg, &form);
33152        assert_eq!(cfg.analysis.style_col_threshold, 80);
33153    }
33154
33155    #[test]
33156    fn style_threshold_absent_leaves_default() {
33157        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33158    }
33159
33160    // ── style_score_threshold ──
33161
33162    #[test]
33163    fn style_score_threshold_zero_when_absent() {
33164        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33165    }
33166
33167    #[test]
33168    fn style_score_threshold_set_to_valid_value() {
33169        let mut form = blank_form();
33170        form.style_score_threshold = Some("70".to_string());
33171        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33172    }
33173
33174    #[test]
33175    fn style_score_threshold_clamps_to_100_when_over() {
33176        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
33177        let mut form = blank_form();
33178        form.style_score_threshold = Some("200".to_string());
33179        assert_eq!(
33180            apply(&form).analysis.style_score_threshold,
33181            100,
33182            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33183        );
33184    }
33185
33186    // ── coverage_file ──
33187
33188    #[test]
33189    fn coverage_file_none_when_absent() {
33190        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33191    }
33192
33193    #[test]
33194    fn coverage_file_none_when_whitespace_only() {
33195        let mut form = blank_form();
33196        form.coverage_file = Some("   ".to_string());
33197        assert!(
33198            apply(&form).analysis.coverage_file.is_none(),
33199            "whitespace-only coverage_file must be treated as None"
33200        );
33201    }
33202
33203    #[test]
33204    fn coverage_file_set_when_non_empty() {
33205        let mut form = blank_form();
33206        form.coverage_file = Some("coverage/lcov.info".to_string());
33207        assert_eq!(
33208            apply(&form).analysis.coverage_file,
33209            Some(std::path::PathBuf::from("coverage/lcov.info"))
33210        );
33211    }
33212
33213    #[test]
33214    fn coverage_file_trims_whitespace() {
33215        let mut form = blank_form();
33216        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
33217        assert_eq!(
33218            apply(&form).analysis.coverage_file,
33219            Some(std::path::PathBuf::from("coverage/lcov.info"))
33220        );
33221    }
33222
33223    // ── report_title ──
33224
33225    #[test]
33226    fn report_title_unchanged_when_absent() {
33227        let original = sloc_config::AppConfig::default().reporting.report_title;
33228        assert_eq!(apply(&blank_form()).reporting.report_title, original);
33229    }
33230
33231    #[test]
33232    fn report_title_unchanged_when_whitespace_only() {
33233        let original = sloc_config::AppConfig::default().reporting.report_title;
33234        let mut form = blank_form();
33235        form.report_title = Some("   ".to_string());
33236        assert_eq!(
33237            apply(&form).reporting.report_title,
33238            original,
33239            "whitespace-only title must not overwrite the default"
33240        );
33241    }
33242
33243    #[test]
33244    fn report_title_updated_and_trimmed() {
33245        let mut form = blank_form();
33246        form.report_title = Some("  My Project  ".to_string());
33247        assert_eq!(apply(&form).reporting.report_title, "My Project");
33248    }
33249
33250    // ── report_header_footer ──
33251
33252    #[test]
33253    fn header_footer_none_when_absent() {
33254        assert!(apply(&blank_form())
33255            .reporting
33256            .report_header_footer
33257            .is_none());
33258    }
33259
33260    #[test]
33261    fn header_footer_none_when_whitespace_only() {
33262        let mut form = blank_form();
33263        form.report_header_footer = Some("  ".to_string());
33264        assert!(apply(&form).reporting.report_header_footer.is_none());
33265    }
33266
33267    #[test]
33268    fn header_footer_set_and_trimmed() {
33269        let mut form = blank_form();
33270        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
33271        assert_eq!(
33272            apply(&form).reporting.report_header_footer,
33273            Some("Confidential — Internal Use".to_string())
33274        );
33275    }
33276
33277    // ── include_globs / exclude_globs ──
33278
33279    #[test]
33280    fn include_globs_empty_when_absent() {
33281        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33282    }
33283
33284    #[test]
33285    fn include_globs_newline_separated() {
33286        let mut form = blank_form();
33287        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33288        assert_eq!(
33289            apply(&form).discovery.include_globs,
33290            vec!["src/**/*.rs", "tests/**/*.rs"]
33291        );
33292    }
33293
33294    #[test]
33295    fn exclude_globs_comma_separated() {
33296        let mut form = blank_form();
33297        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33298        assert_eq!(
33299            apply(&form).discovery.exclude_globs,
33300            vec!["vendor/**", "node_modules/**"]
33301        );
33302    }
33303
33304    #[test]
33305    fn globs_mixed_separators() {
33306        let mut form = blank_form();
33307        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33308        assert_eq!(
33309            apply(&form).discovery.exclude_globs,
33310            vec!["a/**", "b/**", "c/**"]
33311        );
33312    }
33313
33314    // ── split_patterns unit tests ──
33315
33316    #[test]
33317    fn split_patterns_none_is_empty() {
33318        assert!(split_patterns(None).is_empty());
33319    }
33320
33321    #[test]
33322    fn split_patterns_empty_string_is_empty() {
33323        assert!(split_patterns(Some("")).is_empty());
33324    }
33325
33326    #[test]
33327    fn split_patterns_whitespace_only_is_empty() {
33328        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
33329    }
33330
33331    #[test]
33332    fn split_patterns_newlines() {
33333        assert_eq!(
33334            split_patterns(Some("a/**\nb/**\nc/**")),
33335            vec!["a/**", "b/**", "c/**"]
33336        );
33337    }
33338
33339    #[test]
33340    fn split_patterns_commas() {
33341        assert_eq!(
33342            split_patterns(Some("a/**,b/**,c/**")),
33343            vec!["a/**", "b/**", "c/**"]
33344        );
33345    }
33346
33347    #[test]
33348    fn split_patterns_mixed() {
33349        assert_eq!(
33350            split_patterns(Some("a/**\nb/**,c/**")),
33351            vec!["a/**", "b/**", "c/**"]
33352        );
33353    }
33354
33355    #[test]
33356    fn split_patterns_trims_whitespace() {
33357        assert_eq!(
33358            split_patterns(Some("  a/**  \n  b/**  ")),
33359            vec!["a/**", "b/**"]
33360        );
33361    }
33362
33363    #[test]
33364    fn split_patterns_filters_empty_entries() {
33365        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33366    }
33367
33368    #[test]
33369    fn split_patterns_single_entry() {
33370        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33371    }
33372}
33373
33374#[cfg(test)]
33375mod utility_tests {
33376    use super::*;
33377    use std::net::IpAddr;
33378    use std::time::Duration;
33379
33380    // ── sanitize_project_label ────────────────────────────────────────────────
33381
33382    #[test]
33383    fn sanitize_simple_name() {
33384        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33385    }
33386
33387    #[test]
33388    fn sanitize_uppercased_lowercased() {
33389        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33390    }
33391
33392    #[test]
33393    fn sanitize_path_extracts_filename() {
33394        assert_eq!(
33395            sanitize_project_label("/home/user/my-project"),
33396            "my-project"
33397        );
33398    }
33399
33400    #[test]
33401    fn sanitize_path_uses_last_component() {
33402        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33403    }
33404
33405    #[test]
33406    fn sanitize_spaces_become_hyphens() {
33407        assert_eq!(sanitize_project_label("my project"), "my-project");
33408    }
33409
33410    #[test]
33411    fn sanitize_non_ascii_become_hyphens() {
33412        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33413    }
33414
33415    #[test]
33416    fn sanitize_all_special_chars_gives_project() {
33417        assert_eq!(sanitize_project_label("!@#$%^"), "project");
33418    }
33419
33420    #[test]
33421    fn sanitize_empty_string_gives_project() {
33422        assert_eq!(sanitize_project_label(""), "project");
33423    }
33424
33425    #[test]
33426    fn sanitize_leading_trailing_hyphens_stripped() {
33427        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33428    }
33429
33430    #[test]
33431    fn sanitize_alphanumeric_preserved() {
33432        assert_eq!(sanitize_project_label("repo123"), "repo123");
33433    }
33434
33435    #[test]
33436    fn sanitize_dots_become_hyphens() {
33437        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33438    }
33439
33440    #[test]
33441    fn sanitize_mixed_slashes_uses_filename() {
33442        // The Windows path separator — on all platforms Path::file_name still works
33443        assert_eq!(sanitize_project_label("project-name"), "project-name");
33444    }
33445
33446    // ── IpRateLimiter ─────────────────────────────────────────────────────────
33447
33448    #[test]
33449    fn rate_limiter_allows_first_request() {
33450        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33451        let ip: IpAddr = "127.0.0.1".parse().unwrap();
33452        assert!(rl.is_allowed(ip));
33453    }
33454
33455    #[test]
33456    fn rate_limiter_blocks_after_limit_reached() {
33457        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33458        let ip: IpAddr = "10.0.0.1".parse().unwrap();
33459        assert!(rl.is_allowed(ip));
33460        assert!(rl.is_allowed(ip));
33461        assert!(rl.is_allowed(ip));
33462        assert!(!rl.is_allowed(ip), "4th request must be blocked");
33463    }
33464
33465    #[test]
33466    fn rate_limiter_allows_requests_up_to_limit() {
33467        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33468        let ip: IpAddr = "10.0.0.2".parse().unwrap();
33469        for _ in 0..5 {
33470            assert!(rl.is_allowed(ip));
33471        }
33472        assert!(!rl.is_allowed(ip), "6th request must be blocked");
33473    }
33474
33475    #[test]
33476    fn rate_limiter_different_ips_are_independent() {
33477        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33478        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33479        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33480        assert!(rl.is_allowed(ip1));
33481        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33482        assert!(rl.is_allowed(ip2), "ip2 must be independent");
33483    }
33484
33485    #[test]
33486    fn rate_limiter_auth_failure_not_locked_below_threshold() {
33487        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33488        let ip: IpAddr = "10.0.0.3".parse().unwrap();
33489        rl.record_auth_failure(ip);
33490        rl.record_auth_failure(ip);
33491        assert!(
33492            !rl.is_auth_locked_out(ip),
33493            "not locked at 2 failures when threshold is 3"
33494        );
33495    }
33496
33497    #[test]
33498    fn rate_limiter_auth_failure_locked_at_threshold() {
33499        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33500        let ip: IpAddr = "10.0.0.4".parse().unwrap();
33501        rl.record_auth_failure(ip);
33502        rl.record_auth_failure(ip);
33503        rl.record_auth_failure(ip);
33504        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33505    }
33506
33507    #[test]
33508    fn rate_limiter_auth_failure_different_ips_independent() {
33509        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33510        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33511        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33512        rl.record_auth_failure(ip1);
33513        rl.record_auth_failure(ip1);
33514        assert!(rl.is_auth_locked_out(ip1));
33515        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33516    }
33517
33518    #[test]
33519    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33520        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33521        let ip: IpAddr = "127.0.0.2".parse().unwrap();
33522        for _ in 0..100 {
33523            assert!(rl.is_allowed(ip));
33524        }
33525    }
33526
33527    // ── strip_unc_prefix ──────────────────────────────────────────────────────
33528
33529    #[test]
33530    fn strip_unc_plain_path_unchanged() {
33531        let p = PathBuf::from("C:\\Users\\user\\project");
33532        let result = strip_unc_prefix(p.clone());
33533        assert_eq!(result, p);
33534    }
33535
33536    #[test]
33537    fn strip_unc_with_drive_prefix_stripped() {
33538        let p = PathBuf::from(r"\\?\C:\Users\user\project");
33539        let result = strip_unc_prefix(p);
33540        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33541    }
33542
33543    #[test]
33544    fn strip_unc_with_network_prefix_stripped() {
33545        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33546        let result = strip_unc_prefix(p);
33547        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33548    }
33549
33550    #[test]
33551    fn strip_unc_linux_path_unchanged() {
33552        let p = PathBuf::from("/home/user/project");
33553        let result = strip_unc_prefix(p.clone());
33554        assert_eq!(result, p);
33555    }
33556
33557    // ── remote_to_commit_url ──────────────────────────────────────────────────
33558
33559    #[test]
33560    fn remote_to_commit_url_github_https() {
33561        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33562        assert_eq!(
33563            url,
33564            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33565        );
33566    }
33567
33568    #[test]
33569    fn remote_to_commit_url_github_ssh() {
33570        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33571        assert_eq!(
33572            url,
33573            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33574        );
33575    }
33576
33577    #[test]
33578    fn remote_to_commit_url_gitlab_uses_dash_commit() {
33579        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33580        assert_eq!(
33581            url,
33582            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33583        );
33584    }
33585
33586    #[test]
33587    fn remote_to_commit_url_bitbucket_uses_commits() {
33588        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33589        assert_eq!(
33590            url,
33591            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33592        );
33593    }
33594
33595    #[test]
33596    fn remote_to_commit_url_unknown_scheme_returns_none() {
33597        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33598        assert!(url.is_none());
33599    }
33600
33601    #[test]
33602    fn remote_to_commit_url_ssh_gitlab() {
33603        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33604        assert!(url.is_some());
33605        let u = url.unwrap();
33606        assert!(
33607            u.contains("/-/commit/sha123"),
33608            "gitlab ssh must use /-/commit/"
33609        );
33610    }
33611
33612    // ── git_clone_dest ────────────────────────────────────────────────────────
33613
33614    #[test]
33615    fn git_clone_dest_github_url_produces_safe_name() {
33616        let dir = PathBuf::from("/tmp/clones");
33617        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33618        let name = dest.file_name().unwrap().to_string_lossy();
33619        assert!(!name.is_empty());
33620        assert!(
33621            name.chars()
33622                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33623            "clone dest must only contain safe chars, got: {name}"
33624        );
33625    }
33626
33627    #[test]
33628    fn git_clone_dest_is_inside_clones_dir() {
33629        let dir = PathBuf::from("/tmp/clones");
33630        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33631        assert!(
33632            dest.starts_with(&dir),
33633            "clone dest must be inside clones_dir"
33634        );
33635    }
33636
33637    #[test]
33638    fn git_clone_dest_truncates_to_80_chars_max() {
33639        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33640        let dir = PathBuf::from("/tmp/clones");
33641        let dest = git_clone_dest(&long_url, &dir);
33642        let name = dest.file_name().unwrap().to_string_lossy();
33643        assert!(
33644            name.len() <= 80,
33645            "clone dest name must be at most 80 chars, got {} chars: {name}",
33646            name.len()
33647        );
33648    }
33649
33650    #[test]
33651    fn git_clone_dest_special_chars_replaced_with_underscore() {
33652        let dir = PathBuf::from("/tmp/clones");
33653        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33654        let name = dest.file_name().unwrap().to_string_lossy();
33655        assert!(
33656            !name.contains('@') && !name.contains(':') && !name.contains('/'),
33657            "special chars must be replaced in clone dest, got: {name}"
33658        );
33659    }
33660
33661    #[test]
33662    fn git_clone_dest_different_urls_differ() {
33663        let dir = PathBuf::from("/tmp/clones");
33664        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33665        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33666        assert_ne!(
33667            a, b,
33668            "different repos must produce different clone dest names"
33669        );
33670    }
33671
33672    #[test]
33673    fn git_clone_dest_same_url_same_result() {
33674        let dir = PathBuf::from("/tmp/clones");
33675        let url = "https://github.com/owner/repo.git";
33676        assert_eq!(
33677            git_clone_dest(url, &dir),
33678            git_clone_dest(url, &dir),
33679            "same URL must always give same clone dest"
33680        );
33681    }
33682
33683    // ── fmt_delta ─────────────────────────────────────────────────────────────
33684
33685    #[test]
33686    fn fmt_delta_positive_has_plus_prefix() {
33687        assert_eq!(fmt_delta(5), "+5");
33688    }
33689
33690    #[test]
33691    fn fmt_delta_negative_no_plus_prefix() {
33692        assert_eq!(fmt_delta(-3), "-3");
33693    }
33694
33695    #[test]
33696    fn fmt_delta_zero() {
33697        assert_eq!(fmt_delta(0), "0");
33698    }
33699
33700    // ── delta_class ───────────────────────────────────────────────────────────
33701
33702    #[test]
33703    fn delta_class_positive_is_pos() {
33704        assert_eq!(delta_class(1), "pos");
33705    }
33706
33707    #[test]
33708    fn delta_class_negative_is_neg() {
33709        assert_eq!(delta_class(-1), "neg");
33710    }
33711
33712    #[test]
33713    fn delta_class_zero_is_zero_class() {
33714        assert_eq!(delta_class(0), "zero");
33715    }
33716
33717    // ── fmt_pct ───────────────────────────────────────────────────────────────
33718
33719    #[test]
33720    fn fmt_pct_zero_baseline_returns_em_dash() {
33721        assert_eq!(fmt_pct(100, 0), "\u{2014}");
33722    }
33723
33724    #[test]
33725    fn fmt_pct_positive_delta_has_plus_sign() {
33726        let result = fmt_pct(10, 100);
33727        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33728    }
33729
33730    #[test]
33731    fn fmt_pct_negative_delta_no_plus_sign() {
33732        let result = fmt_pct(-10, 100);
33733        assert!(!result.starts_with('+'), "unexpected + in: {result}");
33734        assert!(result.contains('%'));
33735    }
33736
33737    #[test]
33738    fn fmt_pct_near_zero_returns_pm_zero() {
33739        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33740    }
33741
33742    // ── summary_delta ─────────────────────────────────────────────────────────
33743
33744    #[test]
33745    fn summary_delta_no_prev_returns_dash_na() {
33746        let (display, class) = summary_delta(10, None);
33747        assert_eq!(display, "\u{2014}");
33748        assert_eq!(class, "na");
33749    }
33750
33751    #[test]
33752    fn summary_delta_increase_is_positive() {
33753        let (display, class) = summary_delta(15, Some(10));
33754        assert_eq!(display, "+5");
33755        assert_eq!(class, "pos");
33756    }
33757
33758    #[test]
33759    fn summary_delta_decrease_is_negative() {
33760        let (display, class) = summary_delta(5, Some(10));
33761        assert_eq!(display, "-5");
33762        assert_eq!(class, "neg");
33763    }
33764
33765    // ── nth_weekday_of_month ──────────────────────────────────────────────────
33766
33767    #[test]
33768    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33769        use chrono::Datelike;
33770        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33771        assert_eq!(d.year(), 2024);
33772        assert_eq!(d.month(), 1);
33773        assert_eq!(d.weekday(), chrono::Weekday::Mon);
33774        assert!(d.day() <= 7);
33775    }
33776
33777    #[test]
33778    fn nth_weekday_second_sunday_march_2024_is_10th() {
33779        use chrono::Datelike;
33780        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33781        assert_eq!(d.weekday(), chrono::Weekday::Sun);
33782        assert_eq!(d.month(), 3);
33783        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33784    }
33785
33786    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
33787
33788    #[test]
33789    fn is_pacific_dst_july_is_true() {
33790        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33791        assert!(is_pacific_dst(dt), "July must be PDT");
33792    }
33793
33794    #[test]
33795    fn is_pacific_dst_january_is_false() {
33796        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33797        assert!(!is_pacific_dst(dt), "January must be PST");
33798    }
33799
33800    #[test]
33801    fn fmt_la_time_summer_shows_pdt() {
33802        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33803        let result = fmt_la_time(dt);
33804        assert!(
33805            result.ends_with("PDT"),
33806            "summer must use PDT, got: {result}"
33807        );
33808    }
33809
33810    #[test]
33811    fn fmt_la_time_winter_shows_pst() {
33812        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33813        let result = fmt_la_time(dt);
33814        assert!(
33815            result.ends_with("PST"),
33816            "winter must use PST, got: {result}"
33817        );
33818    }
33819
33820    #[test]
33821    fn fmt_la_time_meta_summer_shows_pdt() {
33822        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33823        let result = fmt_la_time_meta(dt);
33824        assert!(
33825            result.ends_with("PDT"),
33826            "meta summer must use PDT, got: {result}"
33827        );
33828    }
33829
33830    #[test]
33831    fn fmt_la_time_meta_winter_shows_pst() {
33832        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33833        let result = fmt_la_time_meta(dt);
33834        assert!(
33835            result.ends_with("PST"),
33836            "meta winter must use PST, got: {result}"
33837        );
33838    }
33839
33840    // ── fmt_git_date ──────────────────────────────────────────────────────────
33841
33842    #[test]
33843    fn fmt_git_date_valid_iso_returns_some() {
33844        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33845    }
33846
33847    #[test]
33848    fn fmt_git_date_invalid_returns_none() {
33849        assert!(fmt_git_date("not-a-date").is_none());
33850    }
33851
33852    // ── format_number ─────────────────────────────────────────────────────────
33853
33854    #[test]
33855    fn format_number_zero() {
33856        assert_eq!(format_number(0), "0");
33857    }
33858
33859    #[test]
33860    fn format_number_three_digits_no_comma() {
33861        assert_eq!(format_number(999), "999");
33862    }
33863
33864    #[test]
33865    fn format_number_four_digits_has_comma() {
33866        assert_eq!(format_number(1000), "1,000");
33867    }
33868
33869    #[test]
33870    fn format_number_seven_digits_two_commas() {
33871        assert_eq!(format_number(1_234_567), "1,234,567");
33872    }
33873
33874    #[test]
33875    fn format_number_one_million() {
33876        assert_eq!(format_number(1_000_000), "1,000,000");
33877    }
33878
33879    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
33880
33881    #[test]
33882    fn badge_text_px_empty_is_zero() {
33883        assert_eq!(badge_text_px(""), 0);
33884    }
33885
33886    #[test]
33887    fn badge_text_px_narrow_chars_smaller_than_normal() {
33888        assert!(
33889            badge_text_px("if") < badge_text_px("ab"),
33890            "'if' must be narrower than 'ab'"
33891        );
33892    }
33893
33894    #[test]
33895    fn badge_text_px_m_is_wider_than_a() {
33896        assert!(
33897            badge_text_px("m") > badge_text_px("a"),
33898            "'m' must be wider than 'a'"
33899        );
33900    }
33901
33902    #[test]
33903    fn render_badge_svg_contains_label_and_value() {
33904        let svg = render_badge_svg("coverage", "95%", "#4c1");
33905        assert!(svg.contains("coverage") && svg.contains("95%"));
33906    }
33907
33908    #[test]
33909    fn render_badge_svg_contains_color() {
33910        let svg = render_badge_svg("sloc", "12K", "#e05d44");
33911        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33912    }
33913
33914    #[test]
33915    fn render_badge_svg_escapes_ampersand_in_label() {
33916        let svg = render_badge_svg("test&label", "ok", "#4c1");
33917        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
33918    }
33919
33920    // ── build_pdf_filename ────────────────────────────────────────────────────
33921
33922    #[test]
33923    fn build_pdf_filename_slugifies_title() {
33924        let name = build_pdf_filename("My Project Report", "abc-def-1234");
33925        assert!(
33926            name.starts_with("my_project_report_")
33927                && std::path::Path::new(&name)
33928                    .extension()
33929                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33930        );
33931    }
33932
33933    #[test]
33934    fn build_pdf_filename_uses_last_run_id_segment() {
33935        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33936        assert!(name.contains("ABCD"), "must use last segment of run_id");
33937    }
33938
33939    #[test]
33940    fn build_pdf_filename_empty_title_uses_report_prefix() {
33941        let name = build_pdf_filename("", "abc-def-9999");
33942        assert!(
33943            name.starts_with("report_")
33944                && std::path::Path::new(&name)
33945                    .extension()
33946                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33947        );
33948    }
33949
33950    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
33951
33952    #[test]
33953    fn swap_chart_js_replaces_inline_block() {
33954        let html = "<html><head><script>// inline source</script></head><body></body></html>";
33955        let result = swap_inline_chart_js_for_static(html.to_string());
33956        assert!(result.contains(r#"src="/static/chart-report.js""#));
33957        assert!(!result.contains("inline source"));
33958    }
33959
33960    #[test]
33961    fn swap_chart_js_no_head_returns_unchanged() {
33962        let html = "<body>no head here</body>";
33963        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33964    }
33965
33966    #[test]
33967    fn swap_chart_js_no_script_in_head_unchanged() {
33968        let html = "<html><head><style>.x{}</style></head><body></body></html>";
33969        let result = swap_inline_chart_js_for_static(html.to_string());
33970        assert!(!result.contains("chart-report.js"));
33971    }
33972
33973    // ── patch_html_nonce ──────────────────────────────────────────────────────
33974
33975    #[test]
33976    fn patch_html_nonce_replaces_old_nonce() {
33977        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33978        let result = patch_html_nonce(html, "new-nonce-456");
33979        assert!(result.contains(r#"nonce="new-nonce-456""#));
33980        assert!(!result.contains("old-nonce-123"));
33981    }
33982
33983    #[test]
33984    fn patch_html_nonce_injects_into_bare_style() {
33985        let html = "<style>body{color:red;}</style>";
33986        let result = patch_html_nonce(html, "fresh-nonce");
33987        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33988    }
33989
33990    #[test]
33991    fn patch_html_nonce_injects_into_bare_script() {
33992        let html = "<script>console.log(1);</script>";
33993        let result = patch_html_nonce(html, "abc");
33994        assert!(result.contains(r#"<script nonce="abc">"#));
33995    }
33996
33997    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
33998
33999    #[test]
34000    fn is_html_report_file_result_html_matches() {
34001        let dir = tempfile::tempdir().unwrap();
34002        let path = dir.path().join("result_20240101.html");
34003        std::fs::write(&path, b"<html></html>").unwrap();
34004        assert!(is_html_report_file(&path));
34005    }
34006
34007    #[test]
34008    fn is_html_report_file_report_html_matches() {
34009        let dir = tempfile::tempdir().unwrap();
34010        let path = dir.path().join("report_abc.html");
34011        std::fs::write(&path, b"<html></html>").unwrap();
34012        assert!(is_html_report_file(&path));
34013    }
34014
34015    #[test]
34016    fn is_html_report_file_index_html_does_not_match() {
34017        let dir = tempfile::tempdir().unwrap();
34018        let path = dir.path().join("index.html");
34019        std::fs::write(&path, b"<html></html>").unwrap();
34020        assert!(!is_html_report_file(&path));
34021    }
34022
34023    #[test]
34024    fn is_html_report_file_nonexistent_returns_false() {
34025        assert!(!is_html_report_file(Path::new(
34026            "/nonexistent/result_xyz.html"
34027        )));
34028    }
34029
34030    #[test]
34031    fn find_html_report_in_dir_finds_result_html() {
34032        let dir = tempfile::tempdir().unwrap();
34033        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34034        assert!(find_html_report_in_dir(dir.path()).is_some());
34035    }
34036
34037    #[test]
34038    fn find_html_report_in_dir_empty_returns_none() {
34039        let dir = tempfile::tempdir().unwrap();
34040        assert!(find_html_report_in_dir(dir.path()).is_none());
34041    }
34042
34043    #[test]
34044    fn find_html_report_in_tree_finds_in_subdir() {
34045        let dir = tempfile::tempdir().unwrap();
34046        let subdir = dir.path().join("run-001");
34047        std::fs::create_dir_all(&subdir).unwrap();
34048        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34049        assert!(find_html_report_in_tree(dir.path()).is_some());
34050    }
34051
34052    // ── derive_project_label ──────────────────────────────────────────────────
34053
34054    #[test]
34055    fn derive_project_label_with_git_repo_and_ref() {
34056        let label = derive_project_label(
34057            Some("https://github.com/owner/my-repo.git"),
34058            Some("main"),
34059            "/fallback/path",
34060        );
34061        assert!(!label.is_empty(), "label must not be empty");
34062        assert!(
34063            label.contains("my") || label.contains("repo"),
34064            "got: {label}"
34065        );
34066    }
34067
34068    #[test]
34069    fn derive_project_label_fallback_to_path() {
34070        let label = derive_project_label(None, None, "/path/to/myproject");
34071        assert_eq!(label, "myproject");
34072    }
34073
34074    #[test]
34075    fn derive_project_label_empty_git_fields_use_path() {
34076        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34077        assert_eq!(label, "cool-app");
34078    }
34079
34080    // ── derive_file_stem ──────────────────────────────────────────────────────
34081
34082    #[test]
34083    fn derive_file_stem_with_commit_appends_sha() {
34084        assert_eq!(
34085            derive_file_stem("myproject", Some("a1b2c3")),
34086            "myproject_a1b2c3"
34087        );
34088    }
34089
34090    #[test]
34091    fn derive_file_stem_without_commit_returns_label() {
34092        assert_eq!(derive_file_stem("myproject", None), "myproject");
34093    }
34094
34095    #[test]
34096    fn derive_file_stem_empty_commit_returns_label() {
34097        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34098    }
34099
34100    // ── split_patterns ────────────────────────────────────────────────────────
34101
34102    #[test]
34103    fn split_patterns_none_is_empty() {
34104        assert!(split_patterns(None).is_empty());
34105    }
34106
34107    #[test]
34108    fn split_patterns_empty_string_is_empty() {
34109        assert!(split_patterns(Some("")).is_empty());
34110    }
34111
34112    #[test]
34113    fn split_patterns_comma_separated() {
34114        assert_eq!(
34115            split_patterns(Some("foo,bar,baz")),
34116            vec!["foo", "bar", "baz"]
34117        );
34118    }
34119
34120    #[test]
34121    fn split_patterns_newline_separated() {
34122        assert_eq!(
34123            split_patterns(Some("foo\nbar\nbaz")),
34124            vec!["foo", "bar", "baz"]
34125        );
34126    }
34127
34128    #[test]
34129    fn split_patterns_trims_whitespace() {
34130        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
34131    }
34132
34133    // ── make_git_label ────────────────────────────────────────────────────────
34134
34135    #[test]
34136    fn make_git_label_empty_repo_empty_result() {
34137        assert_eq!(make_git_label("", "main"), "");
34138    }
34139
34140    #[test]
34141    fn make_git_label_empty_ref_empty_result() {
34142        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34143    }
34144
34145    #[test]
34146    fn make_git_label_basic_format() {
34147        assert_eq!(
34148            make_git_label("https://github.com/owner/my-repo.git", "main"),
34149            "my-repo_at_main_sloc"
34150        );
34151    }
34152
34153    #[test]
34154    fn make_git_label_slash_in_ref_replaced() {
34155        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34156        assert!(
34157            !label.contains('/'),
34158            "slash in ref must be replaced: {label}"
34159        );
34160    }
34161
34162    // ── format_dir_size ───────────────────────────────────────────────────────
34163
34164    #[test]
34165    fn format_dir_size_bytes() {
34166        assert_eq!(format_dir_size(500), "500 B");
34167    }
34168
34169    #[test]
34170    fn format_dir_size_kilobytes() {
34171        assert_eq!(format_dir_size(2048), "2 KB");
34172    }
34173
34174    #[test]
34175    fn format_dir_size_megabytes() {
34176        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34177    }
34178
34179    #[test]
34180    fn format_dir_size_gigabytes() {
34181        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34182    }
34183
34184    #[test]
34185    fn format_dir_size_zero() {
34186        assert_eq!(format_dir_size(0), "0 B");
34187    }
34188
34189    // ── civil_from_days ───────────────────────────────────────────────────────
34190
34191    #[test]
34192    fn civil_from_days_epoch() {
34193        assert_eq!(civil_from_days(0), (1970, 1, 1));
34194    }
34195
34196    #[test]
34197    fn civil_from_days_one_year_later() {
34198        assert_eq!(civil_from_days(365), (1971, 1, 1));
34199    }
34200
34201    #[test]
34202    fn civil_from_days_31_days_is_feb_1_1970() {
34203        assert_eq!(civil_from_days(31), (1970, 2, 1));
34204    }
34205
34206    // ── format_system_time ────────────────────────────────────────────────────
34207
34208    #[test]
34209    fn format_system_time_unix_epoch_formats_correctly() {
34210        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34211    }
34212
34213    #[test]
34214    fn format_system_time_31_days_after_epoch() {
34215        let t = UNIX_EPOCH + Duration::from_hours(744);
34216        assert_eq!(format_system_time(t), "1970-02-01 00:00");
34217    }
34218
34219    #[test]
34220    fn format_system_time_before_epoch_returns_dash() {
34221        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34222            assert_eq!(format_system_time(before), "-");
34223        }
34224    }
34225
34226    // ── detect_language_name ──────────────────────────────────────────────────
34227
34228    #[test]
34229    fn detect_language_name_dot_c() {
34230        assert_eq!(detect_language_name("main.c"), Some("C"));
34231    }
34232
34233    #[test]
34234    fn detect_language_name_dot_h() {
34235        assert_eq!(detect_language_name("defs.h"), Some("C"));
34236    }
34237
34238    #[test]
34239    fn detect_language_name_dot_cpp() {
34240        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34241    }
34242
34243    #[test]
34244    fn detect_language_name_dot_py() {
34245        assert_eq!(detect_language_name("script.py"), Some("Python"));
34246    }
34247
34248    #[test]
34249    fn detect_language_name_dot_ps1() {
34250        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34251    }
34252
34253    #[test]
34254    fn detect_language_name_dot_cs() {
34255        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34256    }
34257
34258    #[test]
34259    fn detect_language_name_dot_sh() {
34260        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34261    }
34262
34263    #[test]
34264    fn detect_language_name_unknown_txt() {
34265        assert_eq!(detect_language_name("notes.txt"), None);
34266    }
34267
34268    // ── language_icon_file ────────────────────────────────────────────────────
34269
34270    #[test]
34271    fn language_icon_file_c() {
34272        assert_eq!(language_icon_file("C"), Some("c.png"));
34273    }
34274
34275    #[test]
34276    fn language_icon_file_python() {
34277        assert_eq!(language_icon_file("Python"), Some("python.png"));
34278    }
34279
34280    #[test]
34281    fn language_icon_file_dockerfile() {
34282        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34283    }
34284
34285    #[test]
34286    fn language_icon_file_rust_is_none() {
34287        assert!(language_icon_file("Rust").is_none());
34288    }
34289
34290    #[test]
34291    fn language_icon_file_unknown_is_none() {
34292        assert!(language_icon_file("Fortran").is_none());
34293    }
34294
34295    // ── language_inline_svg ───────────────────────────────────────────────────
34296
34297    #[test]
34298    fn language_inline_svg_rust_is_svg() {
34299        let svg = language_inline_svg("Rust").unwrap();
34300        assert!(svg.starts_with("<svg"));
34301    }
34302
34303    #[test]
34304    fn language_inline_svg_typescript_is_some() {
34305        assert!(language_inline_svg("TypeScript").is_some());
34306    }
34307
34308    #[test]
34309    fn language_inline_svg_unknown_is_none() {
34310        assert!(language_inline_svg("Fortran").is_none());
34311    }
34312
34313    // ── classify_preview_file ─────────────────────────────────────────────────
34314
34315    #[test]
34316    fn classify_preview_file_c_supported() {
34317        assert!(matches!(
34318            classify_preview_file("main.c"),
34319            PreviewKind::Supported
34320        ));
34321    }
34322
34323    #[test]
34324    fn classify_preview_file_python_supported() {
34325        assert!(matches!(
34326            classify_preview_file("script.py"),
34327            PreviewKind::Supported
34328        ));
34329    }
34330
34331    #[test]
34332    fn classify_preview_file_png_skipped() {
34333        assert!(matches!(
34334            classify_preview_file("image.png"),
34335            PreviewKind::Skipped
34336        ));
34337    }
34338
34339    #[test]
34340    fn classify_preview_file_zip_skipped() {
34341        assert!(matches!(
34342            classify_preview_file("archive.zip"),
34343            PreviewKind::Skipped
34344        ));
34345    }
34346
34347    #[test]
34348    fn classify_preview_file_min_js_skipped() {
34349        assert!(matches!(
34350            classify_preview_file("bundle.min.js"),
34351            PreviewKind::Skipped
34352        ));
34353    }
34354
34355    #[test]
34356    fn classify_preview_file_rs_unsupported() {
34357        assert!(matches!(
34358            classify_preview_file("main.rs"),
34359            PreviewKind::Unsupported
34360        ));
34361    }
34362
34363    // ── preview_relative_path ─────────────────────────────────────────────────
34364
34365    #[test]
34366    fn preview_relative_path_strips_root() {
34367        let root = PathBuf::from("/project");
34368        let path = PathBuf::from("/project/src/main.c");
34369        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34370    }
34371
34372    #[test]
34373    fn preview_relative_path_unrooted_includes_filename() {
34374        let root = PathBuf::from("/other");
34375        let path = PathBuf::from("/project/src/main.c");
34376        let result = preview_relative_path(&root, &path);
34377        assert!(result.contains("main.c"));
34378    }
34379
34380    #[test]
34381    fn preview_relative_path_uses_forward_slashes() {
34382        let root = PathBuf::from("/project");
34383        let path = PathBuf::from("/project/a/b/c.py");
34384        assert!(!preview_relative_path(&root, &path).contains('\\'));
34385    }
34386
34387    // ── wildcard_match ────────────────────────────────────────────────────────
34388
34389    #[test]
34390    fn wildcard_match_exact_equal() {
34391        assert!(wildcard_match("foo", "foo"));
34392    }
34393
34394    #[test]
34395    fn wildcard_match_exact_mismatch() {
34396        assert!(!wildcard_match("foo", "bar"));
34397    }
34398
34399    #[test]
34400    fn wildcard_match_star_suffix() {
34401        assert!(wildcard_match("*.rs", "main.rs"));
34402    }
34403
34404    #[test]
34405    fn wildcard_match_star_middle_requires_suffix() {
34406        assert!(!wildcard_match("a*b", "ac"));
34407    }
34408
34409    #[test]
34410    fn wildcard_match_question_mark_single_char() {
34411        assert!(wildcard_match("f?o", "foo"));
34412    }
34413
34414    #[test]
34415    fn wildcard_match_double_star_nested() {
34416        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34417    }
34418
34419    #[test]
34420    fn wildcard_match_star_directory_entry() {
34421        assert!(wildcard_match("vendor/*", "vendor/crate"));
34422    }
34423
34424    #[test]
34425    fn wildcard_match_no_cross_prefix() {
34426        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34427    }
34428
34429    // ── should_skip_preview_directory ────────────────────────────────────────
34430
34431    #[test]
34432    fn should_skip_empty_relative_is_false() {
34433        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34434    }
34435
34436    #[test]
34437    fn should_skip_matching_pattern() {
34438        assert!(should_skip_preview_directory(
34439            "vendor",
34440            &["vendor".to_string()]
34441        ));
34442    }
34443
34444    #[test]
34445    fn should_skip_non_matching() {
34446        assert!(!should_skip_preview_directory(
34447            "src",
34448            &["vendor".to_string()]
34449        ));
34450    }
34451
34452    #[test]
34453    fn should_skip_wildcard_prefix() {
34454        assert!(should_skip_preview_directory(
34455            "target/debug",
34456            &["target*".to_string()]
34457        ));
34458    }
34459
34460    // ── should_include_preview_file ───────────────────────────────────────────
34461
34462    #[test]
34463    fn should_include_empty_relative_always_true() {
34464        assert!(should_include_preview_file("", &[], &[]));
34465    }
34466
34467    #[test]
34468    fn should_include_no_patterns_includes_all() {
34469        assert!(should_include_preview_file("src/main.c", &[], &[]));
34470    }
34471
34472    #[test]
34473    fn should_include_excluded_by_pattern() {
34474        assert!(!should_include_preview_file(
34475            "vendor/lib.c",
34476            &[],
34477            &["vendor/*".to_string()]
34478        ));
34479    }
34480
34481    #[test]
34482    fn should_include_include_pattern_filters() {
34483        assert!(!should_include_preview_file(
34484            "tests/test_foo.c",
34485            &["src/*".to_string()],
34486            &[]
34487        ));
34488    }
34489
34490    // ── escape_html ───────────────────────────────────────────────────────────
34491
34492    #[test]
34493    fn escape_html_ampersand() {
34494        assert_eq!(escape_html("a&b"), "a&amp;b");
34495    }
34496
34497    #[test]
34498    fn escape_html_angle_brackets() {
34499        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
34500    }
34501
34502    #[test]
34503    fn escape_html_double_quote() {
34504        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
34505    }
34506
34507    #[test]
34508    fn escape_html_single_quote() {
34509        assert_eq!(escape_html("it's"), "it&#39;s");
34510    }
34511
34512    #[test]
34513    fn escape_html_plain_text_unchanged() {
34514        assert_eq!(escape_html("hello world"), "hello world");
34515    }
34516
34517    // ── sum_added / removed / unmodified code lines ───────────────────────────
34518
34519    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34520        sloc_core::ScanComparison {
34521            summary: sloc_core::SummaryDelta {
34522                baseline_run_id: "base".to_string(),
34523                current_run_id: "curr".to_string(),
34524                baseline_timestamp: chrono::Utc::now(),
34525                current_timestamp: chrono::Utc::now(),
34526                baseline_files: 4,
34527                current_files: 4,
34528                files_analyzed_delta: 0,
34529                baseline_code: 330,
34530                current_code: 400,
34531                code_lines_delta: 70,
34532                baseline_comments: 0,
34533                current_comments: 0,
34534                comment_lines_delta: 0,
34535                blank_lines_delta: 0,
34536                total_lines_delta: 70,
34537                coverage_lines_hit_delta: None,
34538                coverage_line_pct_delta: None,
34539                baseline_coverage_line_pct: None,
34540                current_coverage_line_pct: None,
34541            },
34542            file_deltas: vec![
34543                sloc_core::FileDelta {
34544                    relative_path: "added.rs".to_string(),
34545                    language: Some("Rust".to_string()),
34546                    status: FileChangeStatus::Added,
34547                    baseline_code: 0,
34548                    current_code: 100,
34549                    code_delta: 100,
34550                    baseline_comment: 0,
34551                    current_comment: 0,
34552                    comment_delta: 0,
34553                    baseline_blank: 0,
34554                    current_blank: 0,
34555                    blank_delta: 0,
34556                    total_delta: 100,
34557                },
34558                sloc_core::FileDelta {
34559                    relative_path: "removed.rs".to_string(),
34560                    language: Some("Rust".to_string()),
34561                    status: FileChangeStatus::Removed,
34562                    baseline_code: 50,
34563                    current_code: 0,
34564                    code_delta: -50,
34565                    baseline_comment: 0,
34566                    current_comment: 0,
34567                    comment_delta: 0,
34568                    baseline_blank: 0,
34569                    current_blank: 0,
34570                    blank_delta: 0,
34571                    total_delta: -50,
34572                },
34573                sloc_core::FileDelta {
34574                    relative_path: "modified.rs".to_string(),
34575                    language: Some("Rust".to_string()),
34576                    status: FileChangeStatus::Modified,
34577                    baseline_code: 80,
34578                    current_code: 100,
34579                    code_delta: 20,
34580                    baseline_comment: 0,
34581                    current_comment: 0,
34582                    comment_delta: 0,
34583                    baseline_blank: 0,
34584                    current_blank: 0,
34585                    blank_delta: 0,
34586                    total_delta: 20,
34587                },
34588                sloc_core::FileDelta {
34589                    relative_path: "unchanged.rs".to_string(),
34590                    language: Some("Rust".to_string()),
34591                    status: FileChangeStatus::Unchanged,
34592                    baseline_code: 200,
34593                    current_code: 200,
34594                    code_delta: 0,
34595                    baseline_comment: 0,
34596                    current_comment: 0,
34597                    comment_delta: 0,
34598                    baseline_blank: 0,
34599                    current_blank: 0,
34600                    blank_delta: 0,
34601                    total_delta: 0,
34602                },
34603            ],
34604            files_added: 1,
34605            files_removed: 1,
34606            files_modified: 1,
34607            files_unchanged: 1,
34608            files_total: 4,
34609        }
34610    }
34611
34612    #[test]
34613    fn sum_added_counts_added_and_positive_modified() {
34614        let cmp = make_mixed_scan_comparison();
34615        assert_eq!(sum_added_code_lines(&cmp), 120);
34616    }
34617
34618    #[test]
34619    fn sum_removed_counts_removed_baseline() {
34620        let cmp = make_mixed_scan_comparison();
34621        assert_eq!(sum_removed_code_lines(&cmp), 50);
34622    }
34623
34624    #[test]
34625    fn sum_unmodified_counts_unchanged_files() {
34626        let cmp = make_mixed_scan_comparison();
34627        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34628    }
34629
34630    // ── detect_coverage_tool ──────────────────────────────────────────────────
34631
34632    #[test]
34633    fn detect_coverage_tool_rust_project() {
34634        let dir = tempfile::tempdir().unwrap();
34635        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34636        let (tool, cmd) = detect_coverage_tool(dir.path());
34637        assert_eq!(tool, Some("cargo-llvm-cov"));
34638        assert!(cmd.is_some());
34639    }
34640
34641    #[test]
34642    fn detect_coverage_tool_java_gradle() {
34643        let dir = tempfile::tempdir().unwrap();
34644        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34645        let (tool, _) = detect_coverage_tool(dir.path());
34646        assert_eq!(tool, Some("jacoco"));
34647    }
34648
34649    #[test]
34650    fn detect_coverage_tool_python_pyproject() {
34651        let dir = tempfile::tempdir().unwrap();
34652        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34653        let (tool, _) = detect_coverage_tool(dir.path());
34654        assert_eq!(tool, Some("pytest-cov"));
34655    }
34656
34657    #[test]
34658    fn detect_coverage_tool_unknown_project() {
34659        let dir = tempfile::tempdir().unwrap();
34660        let (tool, cmd) = detect_coverage_tool(dir.path());
34661        assert!(tool.is_none() && cmd.is_none());
34662    }
34663
34664    // ── sanitize_path_str / display_path ─────────────────────────────────────
34665
34666    #[test]
34667    fn sanitize_path_str_unc_drive_stripped() {
34668        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34669    }
34670
34671    #[test]
34672    fn sanitize_path_str_unc_network_stripped() {
34673        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34674    }
34675
34676    #[test]
34677    fn sanitize_path_str_plain_path_unchanged() {
34678        assert_eq!(
34679            sanitize_path_str("/home/user/project"),
34680            "/home/user/project"
34681        );
34682    }
34683
34684    #[test]
34685    fn display_path_plain_linux_unchanged() {
34686        assert_eq!(
34687            display_path(Path::new("/home/user/project")),
34688            "/home/user/project"
34689        );
34690    }
34691
34692    #[test]
34693    fn display_path_unc_drive_stripped() {
34694        let result = display_path(Path::new(r"\\?\C:\Users\user"));
34695        assert_eq!(result, r"C:\Users\user");
34696    }
34697
34698    #[test]
34699    fn display_path_unc_network_stripped() {
34700        let result = display_path(Path::new(r"\\?\UNC\server\share"));
34701        assert_eq!(result, r"\\server\share");
34702    }
34703}
34704
34705#[cfg(test)]
34706mod coverage_boost_unit_tests {
34707    use super::*;
34708    use std::path::{Path, PathBuf};
34709
34710    // Both scenarios live in one test (sequential, under a Tokio runtime) because
34711    // load_runtime_security_config spawns a pruning task and mutates process-global
34712    // env vars — parallel sub-tests would race on both.
34713    #[tokio::test]
34714    async fn runtime_security_config_scenarios() {
34715        std::env::remove_var("SLOC_API_KEYS");
34716        std::env::remove_var("SLOC_API_KEY");
34717        std::env::remove_var("SLOC_TLS_CERT");
34718        std::env::remove_var("SLOC_TLS_KEY");
34719        std::env::remove_var("SLOC_TRUST_PROXY");
34720        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34721        let cfg = load_runtime_security_config(false);
34722        assert!(cfg.api_keys.is_empty());
34723        assert!(!cfg.tls_enabled);
34724        assert!(!cfg.trust_proxy);
34725
34726        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
34727        std::env::set_var("SLOC_TRUST_PROXY", "1");
34728        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
34729        std::env::set_var("SLOC_RATE_LIMIT", "250");
34730        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
34731        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
34732        let cfg = load_runtime_security_config(true);
34733        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34734        assert!(cfg.trust_proxy);
34735        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34736        std::env::remove_var("SLOC_API_KEYS");
34737        std::env::remove_var("SLOC_TRUST_PROXY");
34738        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34739        std::env::remove_var("SLOC_RATE_LIMIT");
34740        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
34741        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
34742    }
34743
34744    #[test]
34745    fn cors_layer_builds_both_modes() {
34746        let _ = build_cors_layer(true);
34747        let _ = build_cors_layer(false);
34748    }
34749
34750    #[test]
34751    fn primary_lan_ip_callable() {
34752        // May be Some or None depending on the host; both are valid.
34753        let _ = primary_lan_ip();
34754    }
34755
34756    #[test]
34757    fn safe_redirect_allows_relative_rejects_absolute() {
34758        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34759        assert_eq!(safe_redirect("https://evil.example/x"), "/");
34760        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34761        assert_eq!(default_redirect(), "/view-reports");
34762    }
34763
34764    #[test]
34765    fn tarball_size_caps_env_override() {
34766        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
34767        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
34768        let (c, d) = parse_tarball_size_caps();
34769        assert_eq!(c, 1024 * 1024);
34770        assert_eq!(d, 2 * 1024 * 1024);
34771        std::env::remove_var("SLOC_MAX_TARBALL_MB");
34772        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
34773        let (c2, _) = parse_tarball_size_caps();
34774        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34775    }
34776
34777    #[test]
34778    fn upload_path_helpers() {
34779        let base = upload_base_dir();
34780        let staged = upload_staging_path("abc123");
34781        assert!(staged.starts_with(&base));
34782        assert!(
34783            is_upload_tmp_path(&staged),
34784            "staging path is an upload tmp path"
34785        );
34786        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34787    }
34788
34789    #[test]
34790    fn git_clones_dir_env_override() {
34791        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34792        let def = resolve_git_clones_dir(Path::new("/out"));
34793        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34794        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34795        assert_eq!(
34796            resolve_git_clones_dir(Path::new("/out")),
34797            PathBuf::from("/custom/clones")
34798        );
34799        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34800    }
34801
34802    #[test]
34803    fn html_report_file_detection() {
34804        let dir = std::env::temp_dir().join("sloc_html_detect");
34805        let _ = std::fs::create_dir_all(&dir);
34806        let good = dir.join("report_x.html");
34807        std::fs::write(&good, "<html></html>").unwrap();
34808        let bad = dir.join("notes.txt");
34809        std::fs::write(&bad, "x").unwrap();
34810        assert!(is_html_report_file(&good));
34811        assert!(!is_html_report_file(&bad));
34812        assert!(find_html_report_in_dir(&dir).is_some());
34813        let _ = std::fs::remove_dir_all(&dir);
34814    }
34815
34816    #[test]
34817    fn multi_delta_class_and_format() {
34818        assert_eq!(multi_delta_class(5), "pos");
34819        assert_eq!(multi_delta_class(-5), "neg");
34820        assert_eq!(multi_delta_class(0), "zero");
34821        assert_eq!(multi_fmt_delta(3), "+3");
34822        assert_eq!(multi_fmt_delta(-3), "-3");
34823        assert_eq!(multi_fmt_delta(0), "0");
34824    }
34825
34826    #[test]
34827    fn git_clone_dest_sanitizes() {
34828        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34829        assert!(dest.starts_with("/clones"));
34830        let name = dest.file_name().unwrap().to_str().unwrap();
34831        assert!(name
34832            .chars()
34833            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34834    }
34835}
34836
34837#[cfg(test)]
34838mod tests_private {
34839    use super::*;
34840    use std::io::Read;
34841
34842    // ── Server-mode fail-closed auth gate ──────────────────────────────────────
34843
34844    #[test]
34845    fn local_mode_never_refuses_start() {
34846        // Desktop / local mode is open by design regardless of key presence.
34847        assert!(!refuse_unauthenticated_server(false, false));
34848        assert!(!refuse_unauthenticated_server(false, true));
34849    }
34850
34851    #[test]
34852    fn server_mode_with_key_is_allowed() {
34853        assert!(!refuse_unauthenticated_server(true, true));
34854    }
34855
34856    // Env-mutating assertions live in one test so they run sequentially: the
34857    // process-global env var would otherwise race across parallel test threads.
34858    #[test]
34859    fn server_mode_auth_gate_respects_optin() {
34860        std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34861        assert!(
34862            refuse_unauthenticated_server(true, false),
34863            "server mode + no key must fail closed by default"
34864        );
34865        std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1");
34866        assert!(
34867            !refuse_unauthenticated_server(true, false),
34868            "explicit opt-in must allow the unauthenticated server"
34869        );
34870        std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34871    }
34872
34873    // ── Zip-slip / path-traversal on tarball extraction ────────────────────────
34874
34875    /// Hand-build a raw USTAR block for `name`/`data`, bypassing `tar::Builder`
34876    /// (which refuses to *write* a `..` path). This lets us feed the *reader* a
34877    /// genuinely malicious archive, which is where the zip-slip guard must hold.
34878    fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34879        let mut h = [0u8; 512];
34880        let nb = name.as_bytes();
34881        h[..nb.len()].copy_from_slice(nb);
34882        h[100..108].copy_from_slice(b"0000644\0");
34883        h[108..116].copy_from_slice(b"0000000\0");
34884        h[116..124].copy_from_slice(b"0000000\0");
34885        h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34886        h[136..148].copy_from_slice(b"00000000000\0");
34887        h[156] = b'0'; // typeflag: regular file
34888        h[257..263].copy_from_slice(b"ustar\0");
34889        h[263..265].copy_from_slice(b"00");
34890        for b in &mut h[148..156] {
34891            *b = b' ';
34892        }
34893        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34894        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34895
34896        let mut out = h.to_vec();
34897        out.extend_from_slice(data);
34898        out.resize(out.len() + (512 - data.len() % 512) % 512, 0); // pad file to 512
34899        out.resize(out.len() + 1024, 0); // two trailing zero blocks
34900        out
34901    }
34902
34903    /// A malicious tar whose entry path escapes the destination via `..` must not
34904    /// write outside the staging directory. Locks in the `tar::Archive::unpack`
34905    /// zip-slip guard as a regression test.
34906    #[tokio::test]
34907    async fn tarball_extraction_blocks_zip_slip() {
34908        use std::io::Write as _;
34909
34910        let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34911        let staging = base.join("staging");
34912        let tar_gz = base.join("evil.tar.gz");
34913        std::fs::create_dir_all(&base).unwrap();
34914
34915        // Write a gzip-compressed tar whose single entry is "../escaped.txt".
34916        {
34917            let f = std::fs::File::create(&tar_gz).unwrap();
34918            let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34919            enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34920                .unwrap();
34921            enc.finish().unwrap().flush().unwrap();
34922        }
34923
34924        // Extraction must not write the escaped file beside the staging directory.
34925        let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34926
34927        let escaped = base.join("escaped.txt");
34928        assert!(
34929            !escaped.exists(),
34930            "zip-slip entry escaped staging to {}",
34931            escaped.display()
34932        );
34933
34934        let _ = std::fs::remove_dir_all(&base);
34935    }
34936
34937    #[test]
34938    fn size_limit_reader_zero_remaining_returns_error() {
34939        let data = b"hello world";
34940        let mut reader = SizeLimitReader {
34941            inner: &data[..],
34942            remaining: 0,
34943        };
34944        let mut buf = [0u8; 4];
34945        assert!(reader.read(&mut buf).is_err());
34946    }
34947
34948    #[test]
34949    fn size_limit_reader_counts_bytes() {
34950        let data = b"hello world";
34951        let mut reader = SizeLimitReader {
34952            inner: &data[..],
34953            remaining: 5,
34954        };
34955        let mut buf = [0u8; 4];
34956        let n = reader.read(&mut buf).unwrap();
34957        assert_eq!(n, 4);
34958        assert_eq!(reader.remaining, 1);
34959    }
34960
34961    #[test]
34962    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34963        let uuid = "12345678-1234-1234-1234-123456789012";
34964        let (id, path) = resolve_or_create_staging(Some(uuid));
34965        assert_eq!(id, uuid);
34966        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34967    }
34968
34969    #[test]
34970    fn resolve_or_create_staging_with_none_creates_new() {
34971        let (id1, _) = resolve_or_create_staging(None);
34972        let (id2, _) = resolve_or_create_staging(None);
34973        assert_ne!(id1, id2);
34974    }
34975
34976    #[test]
34977    fn resolve_or_create_staging_with_path_separator_creates_new() {
34978        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
34979        let (id, _) = resolve_or_create_staging(Some("has/slash"));
34980        assert_ne!(id, "has/slash");
34981    }
34982
34983    #[test]
34984    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34985        use std::net::IpAddr;
34986        use std::str::FromStr;
34987        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34988        let ip = IpAddr::from_str("192.168.1.1").unwrap();
34989        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34990    }
34991
34992    #[test]
34993    fn is_auth_locked_out_expired_entry_removed() {
34994        use std::net::IpAddr;
34995        use std::str::FromStr;
34996        let limiter = IpRateLimiter::new(
34997            Duration::from_mins(1),
34998            100,
34999            1, // 1 failure triggers lockout
35000            Duration::from_millis(1),
35001        );
35002        let ip = IpAddr::from_str("192.168.1.2").unwrap();
35003        limiter.record_auth_failure(ip);
35004        // Wait for the 1ms window to expire
35005        std::thread::sleep(Duration::from_millis(10));
35006        // Expired entry should be removed, returning false
35007        assert!(!limiter.is_auth_locked_out(ip));
35008    }
35009
35010    #[test]
35011    fn is_auth_locked_out_within_window_returns_true() {
35012        use std::net::IpAddr;
35013        use std::str::FromStr;
35014        let limiter = IpRateLimiter::new(
35015            Duration::from_mins(1),
35016            100,
35017            2, // 2 failures triggers lockout
35018            Duration::from_hours(1),
35019        );
35020        let ip = IpAddr::from_str("192.168.1.3").unwrap();
35021        limiter.record_auth_failure(ip);
35022        limiter.record_auth_failure(ip);
35023        assert!(limiter.is_auth_locked_out(ip));
35024    }
35025
35026    // ── output_folder_hint ───────────────────────────────────────────────────────
35027
35028    #[test]
35029    fn output_folder_hint_strips_json_subdir() {
35030        use std::path::Path;
35031        let path = Path::new("/output/scan1/json/result.json");
35032        let hint = output_folder_hint(path);
35033        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35034    }
35035
35036    #[test]
35037    fn output_folder_hint_strips_html_subdir() {
35038        use std::path::Path;
35039        let path = Path::new("/output/scan1/html/report.html");
35040        let hint = output_folder_hint(path);
35041        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35042    }
35043
35044    #[test]
35045    fn output_folder_hint_strips_pdf_subdir() {
35046        use std::path::Path;
35047        let path = Path::new("/output/scan1/pdf/report.pdf");
35048        let hint = output_folder_hint(path);
35049        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35050    }
35051
35052    #[test]
35053    fn output_folder_hint_strips_excel_subdir() {
35054        use std::path::Path;
35055        let path = Path::new("/output/scan1/excel/report.xlsx");
35056        let hint = output_folder_hint(path);
35057        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35058    }
35059
35060    #[test]
35061    fn output_folder_hint_flat_layout_returns_direct_parent() {
35062        use std::path::Path;
35063        let path = Path::new("/output/scan1/result.json");
35064        let hint = output_folder_hint(path);
35065        assert!(
35066            hint.ends_with("scan1"),
35067            "expected direct parent, got: {hint}"
35068        );
35069    }
35070
35071    #[test]
35072    fn output_folder_hint_other_subdir_name_not_stripped() {
35073        use std::path::Path;
35074        // "data" is not one of the named artifact subdirs — parent is kept as-is
35075        let path = Path::new("/output/scan1/data/result.json");
35076        let hint = output_folder_hint(path);
35077        assert!(
35078            hint.ends_with("data"),
35079            "non-artifact subdir must not be stripped, got: {hint}"
35080        );
35081    }
35082
35083    // ── find_file_by_ext ─────────────────────────────────────────────────────────
35084
35085    #[test]
35086    fn find_file_by_ext_finds_matching_file() {
35087        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35088        let _ = fs::create_dir_all(&dir);
35089        let f = dir.join("report.pdf");
35090        let _ = fs::write(&f, b"dummy");
35091        let result = find_file_by_ext(&dir, "pdf");
35092        assert!(result.is_some(), "expected to find report.pdf");
35093        let _ = fs::remove_dir_all(&dir);
35094    }
35095
35096    #[test]
35097    fn find_file_by_ext_returns_none_for_missing_ext() {
35098        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35099        let _ = fs::create_dir_all(&dir);
35100        let f = dir.join("report.json");
35101        let _ = fs::write(&f, b"{}");
35102        let result = find_file_by_ext(&dir, "pdf");
35103        assert!(result.is_none());
35104        let _ = fs::remove_dir_all(&dir);
35105    }
35106
35107    #[test]
35108    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35109        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35110        assert!(find_file_by_ext(dir, "json").is_none());
35111    }
35112
35113    // ── collect_result_json_candidates ───────────────────────────────────────────
35114
35115    #[test]
35116    fn collect_result_json_candidates_flat_root() {
35117        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35118        let _ = fs::create_dir_all(&root);
35119        let _ = fs::write(root.join("result.json"), b"{}");
35120        let candidates = collect_result_json_candidates(&root);
35121        assert!(!candidates.is_empty(), "should find result.json at root");
35122        let _ = fs::remove_dir_all(&root);
35123    }
35124
35125    #[test]
35126    fn collect_result_json_candidates_legacy_subdir() {
35127        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35128        let sub = root.join("scanA");
35129        let _ = fs::create_dir_all(&sub);
35130        let _ = fs::write(sub.join("result.json"), b"{}");
35131        let candidates = collect_result_json_candidates(&root);
35132        assert!(
35133            !candidates.is_empty(),
35134            "should find result.json in legacy subdir"
35135        );
35136        let _ = fs::remove_dir_all(&root);
35137    }
35138
35139    #[test]
35140    fn collect_result_json_candidates_structured_json_subdir() {
35141        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35142        let json_sub = root.join("scanB").join("json");
35143        let _ = fs::create_dir_all(&json_sub);
35144        let _ = fs::write(json_sub.join("result.json"), b"{}");
35145        let candidates = collect_result_json_candidates(&root);
35146        assert!(
35147            !candidates.is_empty(),
35148            "should find result.json inside <subdir>/json/"
35149        );
35150        let _ = fs::remove_dir_all(&root);
35151    }
35152
35153    #[test]
35154    fn collect_result_json_candidates_empty_dir() {
35155        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35156        let _ = fs::create_dir_all(&root);
35157        let candidates = collect_result_json_candidates(&root);
35158        assert!(candidates.is_empty());
35159        let _ = fs::remove_dir_all(&root);
35160    }
35161}