Skip to main content

sloc_web/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod audit;
26pub use audit::{AuditVerifyReport, verify_audit_file};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod error;
30pub(crate) mod git_browser;
31pub(crate) mod git_webhook;
32pub(crate) mod integrations;
33
34use std::{
35    collections::{HashMap, VecDeque},
36    fmt::Write,
37    fs,
38    net::{IpAddr, SocketAddr},
39    path::{Path, PathBuf},
40    process::Stdio,
41    sync::{Arc, OnceLock},
42    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
43};
44
45use anyhow::{Context, Result};
46use askama::Template;
47use axum::{
48    Json, Router,
49    body::Body,
50    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
51    http::{HeaderValue, Request, StatusCode, header},
52    middleware::{self, Next},
53    response::{Html, IntoResponse, Response},
54    routing::{get, post},
55};
56use serde::{Deserialize, Serialize};
57use tokio::sync::Mutex;
58use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
59
60use sloc_config::{
61    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
62    MixedLinePolicy,
63};
64use sloc_git::ScheduleStore;
65
66#[derive(Clone)]
67pub(crate) struct CspNonce(pub(crate) String);
68
69static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
70static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
71
72use sloc_core::{
73    AnalysisRun, CleanupPolicy, CleanupPolicyStore, FileChangeStatus, MultiScanComparison,
74    RegistryEntry, ScanRegistry, ScanSummarySnapshot, SummaryTotals, WatchedDirsStore, analyze,
75    compute_delta, compute_multi_delta, read_json,
76};
77use sloc_report::{
78    ReportDeltaContext, render_html, render_html_with_delta, render_sub_report_html,
79    write_pdf_from_html, write_pdf_from_run,
80};
81const MAX_CONCURRENT_ANALYSES: usize = 4;
82
83/// Windows-only helpers that force the native file-picker dialog into the
84/// foreground instead of appearing minimised behind other windows.
85///
86/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
87/// foreground thread so that windows created on our thread inherit focus; and
88/// (b) spin a polling watcher that finds the dialog by title and calls
89/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
90#[cfg(target_os = "windows")]
91#[allow(clippy::upper_case_acronyms)]
92#[allow(dead_code)]
93mod win_dialog_focus {
94    #[cfg(feature = "native-dialog")]
95    use std::mem::size_of;
96
97    type HWND = *mut core::ffi::c_void;
98    type DWORD = u32;
99    type UINT = u32;
100    type BOOL = i32;
101
102    // Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
103    #[cfg(feature = "native-dialog")]
104    #[repr(C)]
105    #[allow(non_snake_case)]
106    struct FLASHWINFO {
107        cbSize: UINT,
108        hwnd: HWND,
109        dwFlags: DWORD,
110        uCount: UINT,
111        dwTimeout: DWORD,
112    }
113
114    #[cfg(feature = "native-dialog")]
115    const FLASHW_ALL: DWORD = 0x3;
116    #[cfg(feature = "native-dialog")]
117    const FLASHW_TIMERNOFG: DWORD = 0xC;
118
119    #[link(name = "user32")]
120    unsafe extern "system" {
121        fn GetForegroundWindow() -> HWND;
122        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
123        fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
124        fn BringWindowToTop(hWnd: HWND) -> BOOL;
125        fn SetWindowPos(
126            hWnd: HWND,
127            hWndAfter: HWND,
128            x: i32,
129            y: i32,
130            cx: i32,
131            cy: i32,
132            flags: UINT,
133        ) -> BOOL;
134        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
135        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
136        #[cfg(feature = "native-dialog")]
137        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
138        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
139        fn FindWindowExW(
140            hWndParent: HWND,
141            hWndChildAfter: HWND,
142            lpszClass: *const u16,
143            lpszWindow: *const u16,
144        ) -> HWND;
145        // Undocumented but present on all Windows versions since XP; bypasses
146        // the foreground-lock that blocks SetForegroundWindow from non-foreground
147        // processes.  fAltTab=1 simulates the Alt+Tab activation path.
148        fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
149    }
150
151    #[link(name = "kernel32")]
152    unsafe extern "system" {
153        fn GetCurrentThreadId() -> DWORD;
154    }
155
156    #[link(name = "shell32")]
157    unsafe extern "system" {
158        // Opens a folder (or file) via the Windows shell.  Passing the current
159        // foreground window as `hwnd` gives the new window proper activation
160        // context so it surfaces in the foreground without needing
161        // AttachThreadInput or SetForegroundWindow hacks.
162        fn ShellExecuteW(
163            hwnd: HWND,
164            lpOperation: *const u16,
165            lpFile: *const u16,
166            lpParameters: *const u16,
167            lpDirectory: *const u16,
168            nShowCmd: i32,
169        ) -> isize; // HINSTANCE (>32 = success)
170    }
171
172    /// Attaches our thread's input to the foreground window's thread so that
173    /// windows created on our thread inherit foreground focus.  Returns the
174    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
175    /// the thread was already the foreground thread.
176    #[cfg(feature = "native-dialog")]
177    pub fn attach_to_foreground() -> DWORD {
178        unsafe {
179            let fg_hwnd = GetForegroundWindow();
180            if fg_hwnd.is_null() {
181                return 0;
182            }
183            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
184            let my_tid = GetCurrentThreadId();
185            if fg_tid == my_tid {
186                return 0;
187            }
188            AttachThreadInput(my_tid, fg_tid, 1);
189            fg_tid
190        }
191    }
192
193    /// Undoes `attach_to_foreground`.
194    #[cfg(feature = "native-dialog")]
195    pub fn detach_from_foreground(fg_tid: DWORD) {
196        if fg_tid == 0 {
197            return;
198        }
199        unsafe {
200            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
201        }
202    }
203
204    unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
205        unsafe {
206            let mut existing = std::collections::HashSet::new();
207            let mut prev: HWND = core::ptr::null_mut();
208            loop {
209                let w = FindWindowExW(
210                    core::ptr::null_mut(),
211                    prev,
212                    class_w.as_ptr(),
213                    core::ptr::null(),
214                );
215                if w.is_null() {
216                    break;
217                }
218                existing.insert(w as usize);
219                prev = w;
220            }
221            existing
222        }
223    }
224
225    unsafe fn find_new_explorer_hwnd(
226        class_w: &[u16],
227        existing: &std::collections::HashSet<usize>,
228    ) -> Option<HWND> {
229        unsafe {
230            let mut prev: HWND = core::ptr::null_mut();
231            loop {
232                let w = FindWindowExW(
233                    core::ptr::null_mut(),
234                    prev,
235                    class_w.as_ptr(),
236                    core::ptr::null(),
237                );
238                if w.is_null() {
239                    return None;
240                }
241                if !existing.contains(&(w as usize)) {
242                    return Some(w);
243                }
244                prev = w;
245            }
246        }
247    }
248
249    unsafe fn bring_to_front(hwnd: HWND) {
250        unsafe {
251            // Surfacing a window owned by another process (Explorer) from a
252            // background thread is blocked by Windows' foreground lock:
253            // SetForegroundWindow silently fails and only the taskbar button
254            // flashes.  The reliable workaround is to temporarily attach our input
255            // queue to the thread that currently owns the foreground window — while
256            // attached, SetForegroundWindow/BringWindowToTop actually activate the
257            // window instead of merely flashing it.
258            let my_tid = GetCurrentThreadId();
259            let fg_hwnd = GetForegroundWindow();
260            let fg_tid = if fg_hwnd.is_null() {
261                0
262            } else {
263                GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
264            };
265            let attached =
266                fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
267
268            // SW_RESTORE = 9 — un-minimise the Explorer window (it may have opened
269            // as a taskbar button) without forcing a full-screen maximise.
270            ShowWindow(hwnd, 9);
271            BringWindowToTop(hwnd);
272            SetForegroundWindow(hwnd);
273            // Extra belt-and-braces activation that also bypasses the foreground
274            // lock on older Windows builds.
275            SwitchToThisWindow(hwnd, 1);
276
277            // Force the Z-order to the very top regardless of the foreground-lock
278            // outcome by flipping TOPMOST on then off, so the window jumps above all
279            // others without staying pinned. HWND_TOPMOST = -1, HWND_NOTOPMOST = -2;
280            // SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE = 0x0013.
281            SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
282            SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
283
284            if attached {
285                AttachThreadInput(my_tid, fg_tid, 0);
286            }
287        }
288    }
289
290    /// Opens `path` in Windows Explorer and forces it to the foreground.
291    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
292    /// caller is not the foreground process (the browser is).  After launching,
293    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
294    /// an undocumented API that bypasses Windows' foreground-lock restriction
295    /// so the window surfaces regardless of which process currently has focus.
296    pub fn open_folder_foreground(path: std::path::PathBuf) {
297        std::thread::spawn(move || {
298            use std::os::windows::ffi::OsStrExt;
299
300            let op: Vec<u16> = "explore\0".encode_utf16().collect();
301            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
302            path_w.push(0);
303            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
304
305            unsafe {
306                // Snapshot every existing Explorer window before we launch so
307                // we can identify the newly created one.
308                let existing = snapshot_explorer_hwnds(&class_w);
309                let fg_hwnd = GetForegroundWindow();
310                // SW_SHOWNORMAL = 1
311                ShellExecuteW(
312                    fg_hwnd,
313                    op.as_ptr(),
314                    path_w.as_ptr(),
315                    core::ptr::null(),
316                    core::ptr::null(),
317                    1,
318                );
319
320                // Poll up to ~3 s for a new CabinetWClass window to appear,
321                // then use SwitchToThisWindow (bypasses foreground-lock) to
322                // bring it in front of the browser and everything else.
323                for _ in 0..40 {
324                    std::thread::sleep(std::time::Duration::from_millis(75));
325                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
326                        bring_to_front(w);
327                        return;
328                    }
329                }
330
331                // Fallback: Explorer reused an existing window — bring whichever
332                // CabinetWClass window is first in Z-order to the front.
333                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
334                if !w.is_null() {
335                    bring_to_front(w);
336                }
337            }
338        });
339    }
340
341    /// Spawns a short-lived watcher thread that polls for a dialog window
342    /// matching `title` and, once found, forces it to the foreground and
343    /// flashes its taskbar button until the user interacts with it.
344    #[cfg(feature = "native-dialog")]
345    pub fn flash_dialog_when_ready(title: String) {
346        std::thread::spawn(move || {
347            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
348            for _ in 0..40 {
349                std::thread::sleep(std::time::Duration::from_millis(80));
350                unsafe {
351                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
352                    if !hwnd.is_null() {
353                        SetForegroundWindow(hwnd);
354                        BringWindowToTop(hwnd);
355                        #[allow(non_snake_case)]
356                        FlashWindowEx(&FLASHWINFO {
357                            // size_of returns usize; Win32 struct field is u32 (UINT).
358                            // struct size fits trivially within u32.
359                            #[allow(clippy::cast_possible_truncation)]
360                            cbSize: size_of::<FLASHWINFO>() as UINT,
361                            hwnd,
362                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
363                            uCount: 3,
364                            dwTimeout: 0,
365                        });
366                        break;
367                    }
368                }
369            }
370        });
371    }
372}
373
374/// Sliding-window rate limiter keyed by client IP.
375/// Uses only std primitives — no external crate required.
376pub(crate) struct IpRateLimiter {
377    window: Duration,
378    max_requests: usize,
379    pub(crate) auth_lockout_threshold: u32,
380    auth_lockout_window: Duration,
381    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
382    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
383}
384
385impl IpRateLimiter {
386    pub(crate) fn new(
387        window: Duration,
388        max_requests: usize,
389        auth_lockout_threshold: u32,
390        auth_lockout_window: Duration,
391    ) -> Self {
392        Self {
393            window,
394            max_requests,
395            auth_lockout_threshold,
396            auth_lockout_window,
397            state: std::sync::Mutex::new(HashMap::new()),
398            auth_failures: std::sync::Mutex::new(HashMap::new()),
399        }
400    }
401
402    // The MutexGuard `state` must live as long as `bucket` borrows from it,
403    // so it cannot be dropped any earlier than the end of the inner block.
404    #[allow(clippy::significant_drop_tightening)]
405    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
406        let now = Instant::now();
407        let cutoff = now.checked_sub(self.window).unwrap_or(now);
408        let mut state = self
409            .state
410            .lock()
411            .unwrap_or_else(std::sync::PoisonError::into_inner);
412        if state.len() > 10_000 {
413            state.retain(|_, bucket| {
414                while bucket.front().is_some_and(|t| *t <= cutoff) {
415                    bucket.pop_front();
416                }
417                !bucket.is_empty()
418            });
419        }
420        let bucket = state.entry(ip).or_default();
421        while bucket.front().is_some_and(|t| *t <= cutoff) {
422            bucket.pop_front();
423        }
424        if bucket.len() >= self.max_requests {
425            false
426        } else {
427            bucket.push_back(now);
428            true
429        }
430    }
431
432    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
433        let now = Instant::now();
434        let mut map = self
435            .auth_failures
436            .lock()
437            .unwrap_or_else(std::sync::PoisonError::into_inner);
438        map.entry(ip)
439            .and_modify(|e| {
440                e.0 += 1;
441                e.1 = now;
442            })
443            .or_insert_with(|| (1, now));
444    }
445
446    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
447        let mut map = self
448            .auth_failures
449            .lock()
450            .unwrap_or_else(std::sync::PoisonError::into_inner);
451        let expired = map
452            .get(&ip)
453            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
454        if expired {
455            map.remove(&ip);
456            return false;
457        }
458        map.get(&ip)
459            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
460    }
461
462    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
463        let map = self
464            .auth_failures
465            .lock()
466            .unwrap_or_else(std::sync::PoisonError::into_inner);
467        map.get(&ip).map_or(0, |e| {
468            self.auth_lockout_window
469                .checked_sub(e.1.elapsed())
470                .map_or(0, |r| r.as_secs())
471        })
472    }
473
474    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
475        tokio::spawn(async move {
476            let mut interval = tokio::time::interval(Duration::from_mins(1));
477            interval.tick().await; // consume the immediate first tick
478            loop {
479                interval.tick().await;
480                let now = Instant::now();
481                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
482                {
483                    let mut state = limiter
484                        .state
485                        .lock()
486                        .unwrap_or_else(std::sync::PoisonError::into_inner);
487                    state.retain(|_, bucket| {
488                        while bucket.front().is_some_and(|t| *t <= cutoff) {
489                            bucket.pop_front();
490                        }
491                        !bucket.is_empty()
492                    });
493                }
494                {
495                    let mut auth = limiter
496                        .auth_failures
497                        .lock()
498                        .unwrap_or_else(std::sync::PoisonError::into_inner);
499                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
500                }
501            }
502        });
503    }
504}
505
506/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
507/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
508/// files but never triggers a scan.
509fn spawn_upload_staging_cleanup() {
510    tokio::spawn(async move {
511        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
512            .ok()
513            .and_then(|v| v.parse().ok())
514            .unwrap_or(4);
515        let ttl_secs = ttl_hours * 3600;
516        let mut interval = tokio::time::interval(Duration::from_hours(1));
517        interval.tick().await; // consume the immediate first tick
518        loop {
519            interval.tick().await;
520            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
521            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
522                continue;
523            };
524            while let Ok(Some(entry)) = dir.next_entry().await {
525                let path = entry.path();
526                let age_secs = tokio::fs::metadata(&path)
527                    .await
528                    .ok()
529                    .and_then(|m| m.modified().ok())
530                    .and_then(|t| t.elapsed().ok())
531                    .map_or(0, |d| d.as_secs());
532                if age_secs > ttl_secs {
533                    tracing::debug!(
534                        event = "upload_staging_cleanup",
535                        path = %path.display(),
536                        age_secs,
537                        "removing stale upload staging directory"
538                    );
539                    let _ = tokio::fs::remove_dir_all(&path).await;
540                }
541            }
542        }
543    });
544}
545
546/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
547#[derive(Clone, Debug, Default)]
548struct RunResultContext {
549    prev_entry: Option<RegistryEntry>,
550    prev_scan_count: usize,
551    project_path: String,
552    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
553    cocomo_mode: String,
554    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
555    complexity_alert: u32,
556    /// Whether duplicate files should be excluded from displayed SLOC totals.
557    #[allow(dead_code)]
558    exclude_duplicates: bool,
559}
560
561/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
562#[derive(Clone)]
563enum AsyncRunState {
564    Running {
565        started_at: std::time::Instant,
566        cancel_token: Arc<std::sync::atomic::AtomicBool>,
567        phase: Arc<std::sync::Mutex<String>>,
568        files_done: Arc<std::sync::atomic::AtomicUsize>,
569        files_total: Arc<std::sync::atomic::AtomicUsize>,
570    },
571    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
572    Complete {
573        run_id: String,
574    },
575    Failed {
576        message: String,
577    },
578    Cancelled,
579}
580
581/// A saved scan configuration profile — stores the form parameters so users can
582/// re-run a favourite scan with one click.
583#[derive(Debug, Clone, Serialize, Deserialize)]
584struct ScanProfile {
585    id: String,
586    name: String,
587    created_at: String,
588    /// The raw scan-form parameters serialized as JSON.
589    params: serde_json::Value,
590}
591
592#[derive(Debug, Clone, Default, Serialize, Deserialize)]
593struct ScanProfileStore {
594    profiles: Vec<ScanProfile>,
595}
596
597impl ScanProfileStore {
598    fn load(path: &std::path::Path) -> Self {
599        fs::read_to_string(path)
600            .ok()
601            .and_then(|s| serde_json::from_str(&s).ok())
602            .unwrap_or_default()
603    }
604
605    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
606        if let Some(parent) = path.parent() {
607            fs::create_dir_all(parent)?;
608        }
609        let json = serde_json::to_string_pretty(self)?;
610        fs::write(path, json)?;
611        Ok(())
612    }
613}
614
615/// Server-side session record. `absolute_expiry` is the hard 8-hour cap (unchanged);
616/// `last_seen` supports the optional sliding idle timeout (see `session_idle_timeout`).
617#[derive(Clone, Copy)]
618pub(crate) struct SessionState {
619    pub(crate) absolute_expiry: Instant,
620    pub(crate) last_seen: Instant,
621}
622
623// The bool fields below are independent runtime flags (server mode, unauth-allow,
624// TLS, proxy trust), not a state machine. Folding them into an enum/sub-struct would
625// churn every construction and access site across this crate for no clarity gain —
626// and that mechanical churn is exactly what risks the new_duplicated_lines_density
627// gate. Scope the allow to this struct rather than refactoring.
628#[allow(clippy::struct_excessive_bools)]
629#[derive(Clone)]
630pub(crate) struct AppState {
631    pub(crate) base_config: AppConfig,
632    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
633    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
634    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
635    pub(crate) registry_path: PathBuf,
636    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
637    pub(crate) server_mode: bool,
638    /// Operator explicitly accepted running server mode with no API key
639    /// (`SLOC_ALLOW_UNAUTHENTICATED=1`). When false, an unauthenticated server-mode
640    /// request fails closed with 503 instead of being served open.
641    pub(crate) allow_unauthenticated: bool,
642    pub(crate) tls_enabled: bool,
643    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
644    /// Read-only credentials (`SLOC_API_KEYS_READONLY`): authenticate for safe
645    /// (GET/HEAD/OPTIONS) requests but are rejected on state-changing methods.
646    /// Empty by default, so all keys are full-access — the prior behaviour.
647    pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
648    pub(crate) rate_limiter: Arc<IpRateLimiter>,
649    pub(crate) trust_proxy: bool,
650    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
651    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
652    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
653    /// Directory where remote repositories are cloned for git-browser scans.
654    pub(crate) git_clones_dir: PathBuf,
655    /// Persisted list of webhook / poll schedules.
656    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
657    pub(crate) schedules_path: PathBuf,
658    /// Named scan profiles saved by the user via the web UI.
659    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
660    pub(crate) scan_profiles_path: PathBuf,
661    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
662    /// Persisted Confluence integration settings.
663    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
664    pub(crate) confluence_path: PathBuf,
665    /// Directories the user has pinned for auto-scanning of external reports.
666    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
667    pub(crate) watched_dirs_path: PathBuf,
668    /// Persisted auto-cleanup policy (age/count limits + interval).
669    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
670    pub(crate) cleanup_policy_path: PathBuf,
671    /// Handle for the running cleanup background task; replaced on policy change.
672    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
673}
674
675type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
676
677/// Parameters for the fire-and-forget HTML + PDF background task.
678
679#[derive(Clone, Debug)]
680pub(crate) struct RunArtifacts {
681    output_dir: PathBuf,
682    html_path: Option<PathBuf>,
683    pdf_path: Option<PathBuf>,
684    json_path: Option<PathBuf>,
685    csv_path: Option<PathBuf>,
686    xlsx_path: Option<PathBuf>,
687    scan_config_path: Option<PathBuf>,
688    report_title: String,
689    result_context: RunResultContext,
690}
691
692#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
693fn build_router(state: AppState) -> Router {
694    let protected = Router::new()
695        .route("/", get(splash))
696        .route("/scan-setup", get(scan_setup_handler))
697        .route("/scan", get(index))
698        .route("/analyze", post(analyze_handler))
699        .route("/preview", get(preview_handler))
700        .route("/api/suggest-coverage", get(api_suggest_coverage))
701        .route("/pick-directory", get(pick_directory_handler))
702        .route("/open-path", get(open_path_handler))
703        .route("/pick-file", get(pick_file_handler))
704        .route(
705            "/api/upload-directory",
706            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
707        )
708        .route(
709            "/api/upload-file",
710            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
711        )
712        .route(
713            "/api/upload-tarball",
714            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
715            // The handler also enforces this limit during streaming so both layers agree.
716            post(upload_tarball_handler)
717                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
718        )
719        .route("/locate-report", post(locate_report_handler))
720        .route("/locate-reports-dir", post(locate_reports_dir_handler))
721        .route("/relocate-scan", post(relocate_scan_handler))
722        .route("/watched-dirs/add", post(add_watched_dir_handler))
723        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
724        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
725        .route("/view-reports", get(history_handler))
726        .route("/compare-scans", get(compare_select_handler))
727        .route("/compare", get(compare_handler))
728        .route("/multi-compare", get(multi_compare_handler))
729        .route("/images/{folder}/{file}", get(image_handler))
730        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
731        .route("/api/metrics/latest", get(api_metrics_latest_handler))
732        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
733        .route("/api/metrics/history", get(api_metrics_history_handler))
734        .route("/api/metrics/churn", get(api_metrics_churn_handler))
735        .route(
736            "/api/metrics/submodules",
737            get(api_metrics_submodules_handler),
738        )
739        .route("/api/ingest", post(api_ingest_handler))
740        .route("/api/project-history", get(project_history_handler))
741        .route("/trend-reports", get(trend_report_handler))
742        .route("/test-metrics", get(test_metrics_handler))
743        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
744        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
745        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
746        .route("/runs/result/{run_id}", get(async_run_result_handler))
747        .route("/embed/summary", get(embed_handler))
748        // ── Git browser ────────────────────────────────────────────────────────
749        .route("/git-browser", get(git_browser::git_browser_handler))
750        .route("/api/git/refs", get(git_browser::api_list_refs))
751        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
752        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
753        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
754        // The request body is the full rendered HTML report, whose size scales
755        // with file count — large repos (Compare Scans, Files, Trend, Test
756        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
757        .route(
758            "/export/pdf",
759            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
760        )
761        // ── Config export / import ─────────────────────────────────────────────
762        .route("/export-config", get(export_config_handler))
763        .route("/import-config", post(import_config_handler))
764        // ── Scan profiles ──────────────────────────────────────────────────────
765        .route("/api/scan-profiles", get(api_list_scan_profiles))
766        .route("/api/scan-profiles", post(api_save_scan_profile))
767        .route(
768            "/api/scan-profiles/{id}",
769            axum::routing::delete(api_delete_scan_profile),
770        )
771        // ── Integrations (webhooks + Confluence) ──────────────────────────────
772        .route("/integrations", get(integrations::integrations_handler))
773        .route(
774            "/webhook-setup",
775            get(|| async { axum::response::Redirect::permanent("/integrations") }),
776        )
777        .route(
778            "/confluence-setup",
779            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
780        )
781        .route("/api/schedules", get(git_webhook::api_list_schedules))
782        .route("/api/schedules", post(git_webhook::api_create_schedule))
783        .route(
784            "/api/schedules",
785            axum::routing::delete(git_webhook::api_delete_schedule),
786        )
787        .route(
788            "/api/confluence/config",
789            get(confluence::api_get_confluence_config),
790        )
791        .route(
792            "/api/confluence/config",
793            post(confluence::api_save_confluence_config),
794        )
795        .route(
796            "/api/confluence/test",
797            post(confluence::api_test_confluence),
798        )
799        .route(
800            "/api/confluence/post",
801            post(confluence::api_post_to_confluence),
802        )
803        .route(
804            "/api/confluence/wiki-markup",
805            get(confluence::api_wiki_markup),
806        )
807        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
808        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
809        .route(
810            "/api/runs/{run_id}",
811            axum::routing::delete(delete_run_handler),
812        )
813        .route("/api/runs/cleanup", post(cleanup_runs_handler))
814        // ── Auto-cleanup policy ────────────────────────────────────────────────
815        .route(
816            "/api/cleanup-policy",
817            get(api_get_cleanup_policy)
818                .post(api_save_cleanup_policy)
819                .delete(api_delete_cleanup_policy),
820        )
821        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
822        // ── REST API reference page ────────────────────────────────────────────
823        .route("/api-docs", get(api_docs_handler))
824        // ── Prometheus metrics — behind API-key auth ───────────────────────────
825        .route("/metrics", get(metrics_handler))
826        .route_layer(middleware::from_fn_with_state(
827            state.clone(),
828            auth::require_api_key,
829        ));
830
831    protected
832        .route("/healthz", get(healthz))
833        .route("/readyz", get(readyz))
834        .route("/api/health", get(api_health_handler))
835        .route("/api/version", get(api_version_handler))
836        .route("/api/openapi.yaml", get(openapi_yaml_handler))
837        .route("/llms.txt", get(llms_txt_handler))
838        .route("/llms-full.txt", get(llms_full_txt_handler))
839        .route("/badge/{metric}", get(badge_handler))
840        .route("/static/chart.js", get(chart_js_handler))
841        .route("/static/chart-report.js", get(report_chart_js_handler))
842        .route("/auth/login", get(auth::auth_login_get))
843        .route("/auth/login", post(auth::auth_login_post))
844        .route("/auth/logout", post(auth::auth_logout))
845        // Pre-access consent acknowledgement endpoint (public; exempt from the gate).
846        .route("/auth/consent", get(auth::auth_consent_accept))
847        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
848        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
849        .route(
850            "/webhooks/github",
851            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
852        )
853        .route(
854            "/webhooks/gitlab",
855            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
856        )
857        .route(
858            "/webhooks/bitbucket",
859            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
860        )
861        // Provider-agnostic build-completion trigger: any upstream CI build (even
862        // a legacy pipeline) can post a small signed JSON body to launch a scan.
863        .route(
864            "/webhooks/ci",
865            post(git_webhook::handle_ci_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
866        )
867        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
868        .layer(middleware::from_fn(consent_gate))
869        .layer(middleware::from_fn(csrf_protect))
870        .layer(middleware::from_fn_with_state(
871            state.clone(),
872            add_security_headers,
873        ))
874        .layer(build_cors_layer(state.server_mode))
875        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
876        // Transparently gzip large text/JSON responses when the client accepts it.
877        .layer(middleware::from_fn(compress_response))
878        // Outermost: bound total request time as a safety net against hung/slow
879        // connections. Generous by default so real scans/PDF exports aren't cut off.
880        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
881            axum::http::StatusCode::REQUEST_TIMEOUT,
882            http_timeout(),
883        ))
884        .with_state(state)
885}
886
887/// Whole-request timeout applied as the outermost layer. A generous safety net
888/// against hung or slow-loris connections that does not cut off legitimate long
889/// operations (large-repo scans, PDF export). Override with
890/// `SLOC_HTTP_TIMEOUT_SECS`; `0` effectively disables it (24h ceiling).
891fn http_timeout() -> std::time::Duration {
892    let secs = std::env::var("SLOC_HTTP_TIMEOUT_SECS")
893        .ok()
894        .and_then(|s| s.trim().parse::<u64>().ok())
895        .unwrap_or(600);
896    std::time::Duration::from_secs(if secs == 0 { 86_400 } else { secs })
897}
898
899// ── Response compression (hand-rolled gzip via flate2) ─────────────────────────
900// A dependency-free alternative to tower-http's CompressionLayer (whose
901// async-compression crate is not in the offline vendor tree). Buffers and gzips
902// only text-like responses of a worthwhile, known size; streaming, already-encoded,
903// or binary/precompressed responses pass through untouched.
904
905/// Don't bother compressing tiny bodies (header overhead outweighs the win).
906const COMPRESS_MIN_BYTES: u64 = 1024;
907/// Never buffer a body larger than this to compress it (memory safety cap).
908const COMPRESS_MAX_BYTES: u64 = 32 * 1024 * 1024;
909
910/// True when the client's `Accept-Encoding` lists gzip.
911fn client_accepts_gzip(headers: &axum::http::HeaderMap) -> bool {
912    headers
913        .get(header::ACCEPT_ENCODING)
914        .and_then(|v| v.to_str().ok())
915        .is_some_and(|val| {
916            val.split(',').any(|enc| {
917                enc.split(';')
918                    .next()
919                    .unwrap_or("")
920                    .trim()
921                    .eq_ignore_ascii_case("gzip")
922            })
923        })
924}
925
926/// Compress text-like payloads only; binary/precompressed types (pdf, gzip, zip,
927/// images, octet-stream) gain nothing and are skipped.
928fn is_compressible_type(content_type: &str) -> bool {
929    let ct = content_type
930        .split(';')
931        .next()
932        .unwrap_or("")
933        .trim()
934        .to_ascii_lowercase();
935    ct.starts_with("text/")
936        || matches!(
937            ct.as_str(),
938            "application/json"
939                | "application/javascript"
940                | "application/xml"
941                | "application/yaml"
942                | "application/manifest+json"
943                | "image/svg+xml"
944        )
945}
946
947/// Middleware: transparently gzip eligible responses when the client accepts it.
948async fn compress_response(req: Request<Body>, next: Next) -> Response {
949    let accepts_gzip = client_accepts_gzip(req.headers());
950    let resp = next.run(req).await;
951    // Skip when the client can't take gzip or the response is already encoded.
952    if !accepts_gzip || resp.headers().contains_key(header::CONTENT_ENCODING) {
953        return resp;
954    }
955    let content_type = resp
956        .headers()
957        .get(header::CONTENT_TYPE)
958        .and_then(|v| v.to_str().ok())
959        .unwrap_or("")
960        .to_owned();
961    if !is_compressible_type(&content_type) {
962        return resp;
963    }
964
965    let (mut parts, body) = resp.into_parts();
966    // Only compress bodies whose exact size is known and worthwhile; pass
967    // streaming/unknown or out-of-band sizes through without buffering.
968    let eligible = matches!(
969        http_body::Body::size_hint(&body).exact(),
970        Some(n) if (COMPRESS_MIN_BYTES..=COMPRESS_MAX_BYTES).contains(&n)
971    );
972    if !eligible {
973        return Response::from_parts(parts, body);
974    }
975
976    let bytes = match axum::body::to_bytes(body, COMPRESS_MAX_BYTES as usize).await {
977        Ok(b) => b,
978        // Guarded against by the size check above; degrade gracefully if hit.
979        Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
980    };
981
982    use std::io::Write as _;
983    let mut encoder = flate2::write::GzEncoder::new(
984        Vec::with_capacity(bytes.len() / 2),
985        flate2::Compression::default(),
986    );
987    if encoder.write_all(&bytes).is_err() {
988        return Response::from_parts(parts, Body::from(bytes));
989    }
990    let compressed = match encoder.finish() {
991        Ok(c) => c,
992        Err(_) => return Response::from_parts(parts, Body::from(bytes)),
993    };
994
995    parts.headers.remove(header::CONTENT_LENGTH);
996    parts
997        .headers
998        .insert(header::CONTENT_LENGTH, HeaderValue::from(compressed.len()));
999    parts
1000        .headers
1001        .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
1002    parts
1003        .headers
1004        .append(header::VARY, HeaderValue::from_static("accept-encoding"));
1005    Response::from_parts(parts, Body::from(compressed))
1006}
1007
1008/// Bearer token used by `make_test_router_server_mode()` test routers.
1009/// Tests that exercise server-mode paths must include this key in their requests.
1010pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
1011
1012/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
1013/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
1014/// builders below start from this and override only the fields they care about.
1015///
1016/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
1017fn test_app_state(tmp_subdir: &str) -> AppState {
1018    // Root every router in its OWN temp subdirectory. Multiple routers share a
1019    // namespace prefix (e.g. "sloc_test"), so a fixed name would make parallel
1020    // tests read/write the same registry.json + artifact tree and race — a
1021    // concurrently-mutated shared store is what made multi_compare_* flaky.
1022    // A per-call counter (plus PID, to avoid leftover-dir collisions across
1023    // runs) guarantees isolation, honouring this fn's "per-test subdir" contract.
1024    static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1025    // FIXME: Audit that the environment access only happens in single-threaded code.
1026    unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
1027    let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1028    let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
1029    AppState {
1030        base_config: AppConfig::default(),
1031        artifacts: Arc::new(Mutex::new(HashMap::new())),
1032        async_runs: Arc::new(Mutex::new(HashMap::new())),
1033        registry: Arc::new(Mutex::new(ScanRegistry::default())),
1034        registry_path: tmp.join("registry.json"),
1035        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1036        server_mode: false,
1037        allow_unauthenticated: false,
1038        tls_enabled: false,
1039        api_keys: Arc::new(vec![]),
1040        readonly_api_keys: Arc::new(vec![]),
1041        rate_limiter: Arc::new(IpRateLimiter::new(
1042            Duration::from_mins(1),
1043            600,
1044            10,
1045            Duration::from_hours(1),
1046        )),
1047        trust_proxy: false,
1048        trusted_proxy_ips: vec![],
1049        git_clones_dir: tmp.join("git-clones"),
1050        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
1051        schedules_path: tmp.join("schedules.json"),
1052        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
1053        scan_profiles_path: tmp.join("scan_profiles.json"),
1054        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1055        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
1056        confluence_path: tmp.join("confluence_config.json"),
1057        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
1058        watched_dirs_path: tmp.join("watched_dirs.json"),
1059        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
1060        cleanup_policy_path: tmp.join("cleanup_policy.json"),
1061        cleanup_task_handle: Arc::new(Mutex::new(None)),
1062    }
1063}
1064
1065/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
1066pub fn make_test_router() -> Router {
1067    build_router(test_app_state("sloc_test"))
1068}
1069
1070/// Test router with one API key pre-loaded. Used by auth integration tests.
1071pub fn make_test_router_with_key(api_key: &str) -> Router {
1072    let mut state = test_app_state("sloc_test_key");
1073    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1074    build_router(state)
1075}
1076
1077/// Test router with a full-access key AND a read-only key.
1078///
1079/// Exercises the read-only credential branch in the auth middleware: a read-only
1080/// key authenticates safe (GET/HEAD/OPTIONS) requests but is rejected with 403 on
1081/// state-changing methods.
1082pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
1083    let mut state = test_app_state("sloc_test_readonly");
1084    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
1085    state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1086        readonly_key.to_owned(),
1087    ))]);
1088    build_router(state)
1089}
1090
1091/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
1092/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
1093/// preview restrictions.
1094pub fn make_test_router_server_mode() -> Router {
1095    let mut state = test_app_state("sloc_test_server");
1096    state.server_mode = true;
1097    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1098        TEST_SERVER_MODE_API_KEY.to_owned(),
1099    ))]);
1100    build_router(state)
1101}
1102
1103/// Server-mode test router with `allowed_scan_roots` configured.
1104///
1105/// Exercises the `validate_server_scan_path` allow/deny branches (in-root
1106/// success, unresolved path, and out-of-root rejection) that the empty-roots
1107/// router cannot reach.
1108pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
1109    let mut state = test_app_state("sloc_test_server_roots");
1110    state.server_mode = true;
1111    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
1112        TEST_SERVER_MODE_API_KEY.to_owned(),
1113    ))]);
1114    state.base_config.discovery.allowed_scan_roots = roots;
1115    build_router(state)
1116}
1117
1118/// Test router where the analysis semaphore is pre-exhausted (0 permits).
1119/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
1120pub fn make_test_router_exhausted_semaphore() -> Router {
1121    let mut state = test_app_state("sloc_test_exhaust");
1122    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
1123    build_router(state)
1124}
1125
1126/// Test router with a very tight rate limit (3 req/min). The third request from
1127/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
1128pub fn make_test_router_tight_rate_limit() -> Router {
1129    let mut state = test_app_state("sloc_test_rate");
1130    state.rate_limiter = Arc::new(IpRateLimiter::new(
1131        Duration::from_mins(1),
1132        2,
1133        5,
1134        Duration::from_secs(5),
1135    ));
1136    build_router(state)
1137}
1138
1139/// Test router with a very tight auth lockout (threshold=2, window=200ms).
1140/// Used by tests that need to trigger and verify the auth lockout response.
1141pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1142    let mut state = test_app_state("sloc_test_auth_lockout");
1143    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1144    state.rate_limiter = Arc::new(IpRateLimiter::new(
1145        Duration::from_mins(1),
1146        600,
1147        2,                          // 2 failures triggers lockout
1148        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
1149    ));
1150    build_router(state)
1151}
1152
1153struct RuntimeSecurityConfig {
1154    api_keys: Vec<secrecy::SecretBox<String>>,
1155    readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1156    tls_cert: Option<String>,
1157    tls_key: Option<String>,
1158    tls_enabled: bool,
1159    trust_proxy: bool,
1160    trusted_proxy_ips: Vec<IpAddr>,
1161    rate_limiter: Arc<IpRateLimiter>,
1162}
1163
1164/// Whether the operator has explicitly opted into running server mode with no API key.
1165/// This is the single escape hatch for the fail-closed server-mode auth requirement.
1166fn allow_unauthenticated_server_mode() -> bool {
1167    matches!(
1168        std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1169        Ok("1" | "true" | "TRUE")
1170    )
1171}
1172
1173/// Fail-closed startup gate: refuse to launch a network-facing server that has no
1174/// authentication configured, unless the operator explicitly accepted the risk.
1175/// Desktop/local mode (`server_mode == false`) is always allowed.
1176fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1177    server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1178}
1179
1180/// Umbrella strict-posture switch (`SLOC_HARDENED=1`). When set, opt-in hardening
1181/// defaults take effect: transport encryption is required on non-loopback binds and
1182/// the auth-lockout threshold tightens. Off by default so existing deployments are
1183/// unaffected; individual controls also keep their own env overrides.
1184fn hardened_mode() -> bool {
1185    std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1186}
1187
1188/// Whether a certificate must be present before serving a network-facing
1189/// (non-loopback) bind. Opt-in via `SLOC_REQUIRE_TLS=1` or `SLOC_HARDENED=1`. Off by
1190/// default, so cleartext and reverse-proxy-terminated deployments keep working.
1191fn require_tls() -> bool {
1192    hardened_mode()
1193        || std::env::var("SLOC_REQUIRE_TLS")
1194            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1195}
1196
1197/// Optional sliding idle timeout for authenticated sessions. `None` (the default)
1198/// means only the 8-hour absolute cap applies — identical to prior behaviour.
1199/// `SLOC_SESSION_IDLE_SECS=<n>` sets an explicit idle limit (`0` disables); under
1200/// `SLOC_HARDENED` it defaults to 15 minutes. Each authenticated request refreshes
1201/// the session's last-seen time, so the window slides.
1202pub(crate) fn session_idle_timeout() -> Option<Duration> {
1203    match std::env::var("SLOC_SESSION_IDLE_SECS")
1204        .ok()
1205        .and_then(|v| v.parse::<u64>().ok())
1206    {
1207        Some(0) => None,
1208        Some(secs) => Some(Duration::from_secs(secs)),
1209        None if hardened_mode() => Some(Duration::from_mins(15)),
1210        None => None,
1211    }
1212}
1213
1214/// Generic authorized-use notice shown when a banner is required but the operator
1215/// has not supplied custom text via `SLOC_CONSENT_BANNER`.
1216const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1217Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1218are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1219
1220/// The pre-access consent banner text, if enabled. `SLOC_CONSENT_BANNER=<text>`
1221/// sets custom wording; `SLOC_HARDENED` alone falls back to a generic notice.
1222/// `None` (the default) disables the banner entirely.
1223fn consent_banner_text() -> Option<String> {
1224    if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1225        let t = t.trim();
1226        if !t.is_empty() {
1227            return Some(t.to_owned());
1228        }
1229    }
1230    hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1231}
1232
1233/// True when this request is a top-level browser navigation that the consent gate
1234/// should intercept. APIs, assets, webhooks, health checks, and the accept
1235/// endpoint itself are never gated.
1236fn consent_gate_applies(req: &Request<Body>) -> bool {
1237    const EXEMPT: &[&str] = &[
1238        "/auth/consent",
1239        "/static/",
1240        "/images/",
1241        "/assets/",
1242        "/badge/",
1243        "/healthz",
1244        "/api/",
1245        "/webhooks/",
1246        "/metrics",
1247        "/favicon",
1248        "/llms",
1249    ];
1250    if !matches!(
1251        *req.method(),
1252        axum::http::Method::GET | axum::http::Method::HEAD
1253    ) {
1254        return false;
1255    }
1256    let is_html = req
1257        .headers()
1258        .get(header::ACCEPT)
1259        .and_then(|v| v.to_str().ok())
1260        .is_some_and(|a| a.contains("text/html"));
1261    if !is_html {
1262        return false;
1263    }
1264    let path = req.uri().path();
1265    !EXEMPT.iter().any(|p| path.starts_with(p))
1266}
1267
1268/// Whether the request already carries the consent acknowledgement cookie.
1269fn request_has_consent(req: &Request<Body>) -> bool {
1270    req.headers()
1271        .get(header::COOKIE)
1272        .and_then(|v| v.to_str().ok())
1273        .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1274}
1275
1276/// Pre-access consent gate. When a banner is configured, browser page navigations
1277/// must acknowledge it (recorded in a session cookie) before proceeding. A no-op
1278/// when unconfigured, so default deployments are unaffected.
1279async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1280    let Some(text) = consent_banner_text() else {
1281        return next.run(req).await;
1282    };
1283    if !consent_gate_applies(&req) || request_has_consent(&req) {
1284        return next.run(req).await;
1285    }
1286    let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1287    render_consent_page(&text, next_path)
1288}
1289
1290/// Minimal escaping for embedding operator/config text into the banner HTML.
1291fn html_escape_consent(s: &str) -> String {
1292    s.replace('&', "&amp;")
1293        .replace('<', "&lt;")
1294        .replace('>', "&gt;")
1295        .replace('"', "&quot;")
1296}
1297
1298/// Render the consent interstitial with an "I Agree" action that records
1299/// acknowledgement and returns the user to where they were headed.
1300fn render_consent_page(text: &str, next_path: &str) -> Response {
1301    // Only accept a safe same-origin relative path as the return target.
1302    let safe_next = if next_path.starts_with('/')
1303        && !next_path.starts_with("//")
1304        && !next_path.contains("://")
1305        && !next_path.starts_with("/auth/")
1306    {
1307        next_path
1308    } else {
1309        "/"
1310    };
1311    let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1312    let body = format!(
1313        r#"<!doctype html><html><head><meta charset="utf-8">
1314<meta name="viewport" content="width=device-width, initial-scale=1">
1315<title>Notice and Consent — OxideSLOC</title>
1316<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1317h1{{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}}
1318.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1319.agree:hover{{background:#a04d27}}</style>
1320</head><body>
1321<h1>Notice and Consent</h1>
1322<div class="notice">{}</div>
1323<a class="agree" href="{}">I Agree</a>
1324</body></html>"#,
1325        html_escape_consent(text),
1326        accept_url
1327    );
1328    (StatusCode::OK, Html(body)).into_response()
1329}
1330
1331/// Emit operator-facing warnings for insecure server-mode configurations.
1332/// Pure side-effect (stdout); no bearing on the returned config values.
1333// The bools are independent configuration facts read from the resolved config, not
1334// a mode enum — folding them into a struct just to pass them here would add
1335// ceremony without clarity. Scope the allow to this diagnostic helper.
1336#[allow(clippy::fn_params_excessive_bools)]
1337fn emit_server_mode_warnings(
1338    server_mode: bool,
1339    api_keys_empty: bool,
1340    tls_enabled: bool,
1341    trust_proxy: bool,
1342    trusted_proxy_ips: &[IpAddr],
1343) {
1344    if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1345        // Absence of a key is a hard startup failure in server mode (enforced by the
1346        // caller, `serve`). The only exception is an explicit operator opt-in via
1347        // SLOC_ALLOW_UNAUTHENTICATED=1 for trusted-LAN testing — warn loudly then.
1348        println!(
1349            "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1350             authentication. Every web endpoint is publicly reachable. Do NOT use this \
1351             outside a trusted, isolated network."
1352        );
1353    }
1354    if server_mode && !tls_enabled {
1355        println!(
1356            "WARNING: TLS is not configured. Traffic is cleartext. \
1357             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1358             or terminate TLS at a reverse proxy (nginx, caddy)."
1359        );
1360    }
1361    if server_mode {
1362        println!(
1363            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1364             to restrict cross-origin access (comma-separated)."
1365        );
1366    }
1367    emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1368    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1369        println!(
1370            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1371             DISABLED for all git operations. Remove this variable before production use."
1372        );
1373    }
1374}
1375
1376/// Emit the reverse-proxy / X-Forwarded-For trust advisory for server mode.
1377fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1378    if trust_proxy {
1379        if trusted_proxy_ips.is_empty() {
1380            println!(
1381                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1382                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1383                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1384            );
1385        } else {
1386            println!(
1387                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1388                trusted_proxy_ips
1389                    .iter()
1390                    .map(std::string::ToString::to_string)
1391                    .collect::<Vec<_>>()
1392                    .join(", ")
1393            );
1394        }
1395    } else if server_mode {
1396        println!(
1397            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1398             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1399             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1400             enable per-client rate limiting via X-Forwarded-For."
1401        );
1402    }
1403}
1404
1405fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1406    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1407        .or_else(|_| std::env::var("SLOC_API_KEY"))
1408        .unwrap_or_default()
1409        .split(',')
1410        .map(str::trim)
1411        .filter(|s| !s.is_empty())
1412        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1413        .collect();
1414    let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1415        std::env::var("SLOC_API_KEYS_READONLY")
1416            .unwrap_or_default()
1417            .split(',')
1418            .map(str::trim)
1419            .filter(|s| !s.is_empty())
1420            .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1421            .collect();
1422    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1423    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1424    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1425    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1426    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1427        .unwrap_or_default()
1428        .split(',')
1429        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1430        .collect();
1431    emit_server_mode_warnings(
1432        server_mode,
1433        api_keys.is_empty(),
1434        tls_enabled,
1435        trust_proxy,
1436        &trusted_proxy_ips,
1437    );
1438    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1439        .ok()
1440        .and_then(|v| v.parse::<u32>().ok())
1441        .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1442    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1443        .ok()
1444        .and_then(|v| v.parse::<u64>().ok())
1445        .unwrap_or(3600);
1446    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1447    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1448    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1449    let default_rpm: usize = if server_mode { 120 } else { 600 };
1450    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1451        .ok()
1452        .and_then(|v| v.parse::<usize>().ok())
1453        .unwrap_or(default_rpm);
1454    let rate_limiter = Arc::new(IpRateLimiter::new(
1455        Duration::from_mins(1),
1456        rate_limit_rpm,
1457        auth_lockout_threshold,
1458        Duration::from_secs(auth_lockout_secs),
1459    ));
1460    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1461    RuntimeSecurityConfig {
1462        api_keys,
1463        readonly_api_keys,
1464        tls_cert,
1465        tls_key,
1466        tls_enabled,
1467        trust_proxy,
1468        trusted_proxy_ips,
1469        rate_limiter,
1470    }
1471}
1472
1473/// # Errors
1474///
1475/// Returns an error if the server fails to bind to the configured address or
1476/// if the TLS configuration cannot be loaded.
1477///
1478/// # Panics
1479///
1480/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1481#[allow(clippy::too_many_lines)]
1482pub async fn serve(config: AppConfig) -> Result<()> {
1483    // Anchor the uptime clock at launch so /api/health reports true process uptime.
1484    process_start();
1485    let bind_address = config.web.bind_address.clone();
1486    let server_mode = config.web.server_mode;
1487    let output_root = resolve_output_root(None);
1488    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1489    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1490        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1491    let mut registry = ScanRegistry::load(&registry_path);
1492    registry.prune_stale();
1493    let _ = registry.save(&registry_path);
1494
1495    let sec = load_runtime_security_config(server_mode);
1496    // Security posture: refuse to start an unauthenticated network-facing server. A server-mode
1497    // launch with no API key would expose every endpoint publicly; fail closed unless the
1498    // operator has explicitly accepted the risk via SLOC_ALLOW_UNAUTHENTICATED=1.
1499    if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1500        audit::record(
1501            "server_start_refused",
1502            "denied",
1503            &[(
1504                "reason",
1505                "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1506            )],
1507        );
1508        anyhow::bail!(
1509            "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1510             (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1511             unauthenticated server on a trusted, isolated network, explicitly set \
1512             SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1513        );
1514    }
1515    if server_mode && sec.api_keys.is_empty() {
1516        audit::record("server_start_unauthenticated", "warning", &[]);
1517    }
1518    spawn_upload_staging_cleanup();
1519
1520    let git_clones_dir = resolve_git_clones_dir(&output_root);
1521    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1522        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1523    let schedules = ScheduleStore::load(&schedules_path);
1524    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1525        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1526    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1527    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1528        |_| output_root.join("confluence_config.json"),
1529        PathBuf::from,
1530    );
1531    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1532    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1533        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1534    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1535    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1536        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1537    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1538
1539    let state = AppState {
1540        base_config: config,
1541        artifacts: Arc::new(Mutex::new(HashMap::new())),
1542        async_runs: Arc::new(Mutex::new(HashMap::new())),
1543        registry: Arc::new(Mutex::new(registry)),
1544        registry_path,
1545        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1546        server_mode,
1547        allow_unauthenticated: allow_unauthenticated_server_mode(),
1548        tls_enabled: sec.tls_enabled,
1549        api_keys: Arc::new(sec.api_keys),
1550        readonly_api_keys: Arc::new(sec.readonly_api_keys),
1551        rate_limiter: sec.rate_limiter,
1552        trust_proxy: sec.trust_proxy,
1553        trusted_proxy_ips: sec.trusted_proxy_ips,
1554        git_clones_dir,
1555        schedules: Arc::new(Mutex::new(schedules)),
1556        schedules_path,
1557        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1558        scan_profiles_path,
1559        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1560        confluence: Arc::new(Mutex::new(confluence)),
1561        confluence_path,
1562        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1563        watched_dirs_path,
1564        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1565        cleanup_policy_path,
1566        cleanup_task_handle: Arc::new(Mutex::new(None)),
1567    };
1568
1569    restart_poll_schedules(&state).await;
1570    warn_insecure_gitlab_webhooks(&state).await;
1571
1572    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1573    {
1574        let enabled = state
1575            .cleanup_policy
1576            .lock()
1577            .await
1578            .policy
1579            .as_ref()
1580            .is_some_and(|p| p.enabled);
1581        if enabled {
1582            let handle = spawn_cleanup_policy_task(state.clone());
1583            *state.cleanup_task_handle.lock().await = Some(handle);
1584        }
1585    }
1586
1587    let app = build_router(state.clone());
1588
1589    // Try the configured port first, then step up through a few alternatives.
1590    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1591    // kernel zombie (visible in netstat but owned by no living process).  Rather
1592    // than failing, we auto-select the next free port and tell the user.
1593    let preferred: SocketAddr = bind_address
1594        .parse()
1595        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1596
1597    // Opt-in transport-encryption gate: refuse to expose a network-facing (non-
1598    // loopback) listener in cleartext when TLS enforcement is requested. Off by
1599    // default; enable with SLOC_REQUIRE_TLS=1 or SLOC_HARDENED=1. Loopback binds
1600    // (including reverse-proxy-terminated setups) are always allowed.
1601    if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1602        audit::record(
1603            "server_start_refused",
1604            "denied",
1605            &[("reason", "TLS required for non-loopback bind")],
1606        );
1607        anyhow::bail!(
1608            "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1609             SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1610             a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1611        );
1612    }
1613
1614    let (listener, addr) = {
1615        let candidates = (0u16..=9).map(|offset| {
1616            let mut a = preferred;
1617            a.set_port(preferred.port().saturating_add(offset));
1618            a
1619        });
1620        let mut found = None;
1621        for candidate in candidates {
1622            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1623                found = Some((l, candidate));
1624                break;
1625            }
1626        }
1627        found.ok_or_else(|| {
1628            anyhow::anyhow!(
1629                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1630                bind_address,
1631                preferred.port(),
1632                preferred.port().saturating_add(9)
1633            )
1634        })?
1635    };
1636    if addr != preferred {
1637        eprintln!(
1638            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1639             using {} instead.",
1640            preferred.port(),
1641            addr.port()
1642        );
1643    }
1644
1645    if sec.tls_enabled {
1646        let cert_path = sec
1647            .tls_cert
1648            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1649        let key_path = sec
1650            .tls_key
1651            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1652        let tls_config = build_tls_config(&cert_path, &key_path)
1653            .context("failed to load TLS certificate/key")?;
1654        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1655
1656        let url = format!("https://{addr}/");
1657        println!("OxideSLOC server running at {url} (TLS)");
1658        if let Some(lan) = wildcard_lan_url(&url) {
1659            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1660        }
1661        println!("Use Ctrl+C to stop.");
1662
1663        return serve_tls(listener, app, acceptor, server_mode).await;
1664    }
1665
1666    let url = format!("http://{addr}/");
1667    log_startup_url(&url, server_mode);
1668
1669    axum::serve(
1670        listener,
1671        app.into_make_service_with_connect_info::<SocketAddr>(),
1672    )
1673    .with_graceful_shutdown(shutdown_signal(server_mode))
1674    .await
1675    .context("web server terminated unexpectedly")
1676}
1677
1678/// Discover the primary non-loopback IPv4 address by asking the OS which
1679/// outbound interface it would use to reach a public address.  No packets are
1680/// sent — the UDP socket is only used to query the routing table.
1681fn primary_lan_ip() -> Option<String> {
1682    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1683    socket.connect("8.8.8.8:80").ok()?;
1684    let addr = socket.local_addr().ok()?;
1685    let ip = addr.ip();
1686    if ip.is_loopback() {
1687        return None;
1688    }
1689    Some(ip.to_string())
1690}
1691
1692/// If `url` binds a wildcard address (`0.0.0.0` or `[::]`), return the same URL
1693/// with the primary LAN IP substituted, so the startup log shows a client-usable
1694/// address alongside the bind address. Returns `None` for concrete binds or when
1695/// no routable LAN address can be determined (e.g. loopback-only / no default route).
1696fn wildcard_lan_url(url: &str) -> Option<String> {
1697    if url.contains("0.0.0.0") {
1698        primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1699    } else if url.contains("[::]") {
1700        primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1701    } else {
1702        None
1703    }
1704}
1705
1706/// Print the startup URL and, in local mode, open the browser and schedule it.
1707fn log_startup_url(url: &str, server_mode: bool) {
1708    if server_mode {
1709        println!("OxideSLOC server running at {url}");
1710        if let Some(lan) = wildcard_lan_url(url) {
1711            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1712        }
1713        println!("Use Ctrl+C to stop.");
1714    } else {
1715        println!("OxideSLOC local web UI running at {url}");
1716        println!("Press Ctrl+C to stop the server.");
1717        let open_url = url.to_owned();
1718        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1719    }
1720}
1721
1722/// Open the given URL in the default system browser.
1723fn open_browser_tab(url: &str) {
1724    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1725    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1726    // first quoted token as a window title — both are fragile and shell-parsed. The
1727    // url.dll handler receives the URL as a single, non-shell argument.
1728    #[cfg(target_os = "windows")]
1729    let _ = std::process::Command::new("rundll32")
1730        .args(["url.dll,FileProtocolHandler", url])
1731        .stdout(Stdio::null())
1732        .stderr(Stdio::null())
1733        .spawn();
1734    #[cfg(target_os = "macos")]
1735    let _ = std::process::Command::new("open")
1736        .arg(url)
1737        .stdout(Stdio::null())
1738        .stderr(Stdio::null())
1739        .spawn();
1740    #[cfg(target_os = "linux")]
1741    let _ = std::process::Command::new("xdg-open")
1742        .arg(url)
1743        .stdout(Stdio::null())
1744        .stderr(Stdio::null())
1745        .spawn();
1746}
1747
1748/// Graceful-shutdown future: resolves on Ctrl-C.
1749async fn shutdown_signal(server_mode: bool) {
1750    if tokio::signal::ctrl_c().await.is_ok() {
1751        println!();
1752        if server_mode {
1753            println!("Shutting down OxideSLOC server...");
1754        } else {
1755            println!("Shutting down OxideSLOC local web UI...");
1756        }
1757        println!("Server stopped cleanly.");
1758    }
1759}
1760
1761/// Load a rustls `ServerConfig` from PEM certificate and key files.
1762fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1763    use rustls_pki_types::pem::PemObject;
1764    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1765
1766    let cert_bytes =
1767        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1768    let key_bytes =
1769        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1770
1771    let cert_chain: Vec<CertificateDer<'static>> =
1772        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1773            .collect::<std::result::Result<_, _>>()
1774            .context("failed to parse TLS certificates")?;
1775
1776    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1777        .context("failed to parse TLS private key")?;
1778
1779    // Explicitly pin the accepted protocol versions to TLS 1.2 and 1.3 (these are
1780    // rustls's safe defaults; stated here so the accepted set is auditable). rustls
1781    // ships only modern AEAD cipher suites — no CBC/RC4/3DES — so no suite pinning is
1782    // needed to exclude weak ciphers.
1783    let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1784        &rustls::version::TLS13,
1785        &rustls::version::TLS12,
1786    ]);
1787
1788    // Opt-in mutual TLS: when SLOC_TLS_CLIENT_CA points to a PEM CA bundle, require
1789    // every client to present a certificate that chains to it — a transport-layer
1790    // factor on top of the application API key. Unset = no client auth (prior
1791    // behaviour).
1792    let config = match client_cert_verifier()? {
1793        Some(verifier) => builder
1794            .with_client_cert_verifier(verifier)
1795            .with_single_cert(cert_chain, key),
1796        None => builder
1797            .with_no_client_auth()
1798            .with_single_cert(cert_chain, key),
1799    };
1800    config.context("failed to build TLS server config")
1801}
1802
1803/// Build a client-certificate verifier when `SLOC_TLS_CLIENT_CA` is configured,
1804/// enabling mutual TLS. Returns `None` (no client auth) when unset — the default.
1805fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1806    use rustls_pki_types::CertificateDer;
1807    use rustls_pki_types::pem::PemObject;
1808
1809    let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1810        .ok()
1811        .filter(|s| !s.is_empty())
1812    else {
1813        return Ok(None);
1814    };
1815    let ca_bytes = fs::read(&ca_path)
1816        .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1817    let mut roots = rustls::RootCertStore::empty();
1818    for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1819        let cert = cert.context("failed to parse client CA certificate")?;
1820        roots
1821            .add(cert)
1822            .context("failed to add client CA certificate to root store")?;
1823    }
1824    let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1825        .build()
1826        .context("failed to build client certificate verifier")?;
1827    Ok(Some(verifier))
1828}
1829
1830/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1831async fn serve_tls(
1832    listener: tokio::net::TcpListener,
1833    app: Router,
1834    acceptor: tokio_rustls::TlsAcceptor,
1835    server_mode: bool,
1836) -> Result<()> {
1837    use hyper_util::rt::{TokioExecutor, TokioIo};
1838    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1839    use hyper_util::service::TowerToHyperService;
1840    use tower::{Service, ServiceExt};
1841
1842    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1843
1844    loop {
1845        tokio::select! {
1846            biased;
1847            _ = tokio::signal::ctrl_c() => {
1848                println!();
1849                if server_mode {
1850                    println!("Shutting down OxideSLOC server...");
1851                } else {
1852                    println!("Shutting down OxideSLOC local web UI...");
1853                }
1854                println!("Server stopped cleanly.");
1855                return Ok(());
1856            }
1857            result = listener.accept() => {
1858                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1859                let acceptor = acceptor.clone();
1860                let mut factory = make_svc.clone();
1861
1862                tokio::spawn(async move {
1863                    let tls = match acceptor.accept(tcp).await {
1864                        Ok(s) => s,
1865                        Err(e) => {
1866                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1867                            return;
1868                        }
1869                    };
1870                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1871                        Ok(f) => match Service::call(f, peer_addr).await {
1872                            Ok(s) => s,
1873                            Err(_) => return,
1874                        },
1875                        Err(_) => return,
1876                    };
1877                    let io = TokioIo::new(tls);
1878                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1879                        .serve_connection(io, TowerToHyperService::new(svc))
1880                        .await
1881                    {
1882                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1883                    }
1884                });
1885            }
1886        }
1887    }
1888}
1889
1890// auth moved to auth.rs
1891
1892fn build_cors_layer(server_mode: bool) -> CorsLayer {
1893    if server_mode {
1894        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1895            .unwrap_or_default()
1896            .split(',')
1897            .filter(|s| !s.is_empty())
1898            .filter_map(|s| s.trim().parse().ok())
1899            .collect();
1900        if allowed.is_empty() {
1901            return CorsLayer::new();
1902        }
1903        CorsLayer::new()
1904            .allow_origin(AllowOrigin::list(allowed))
1905            .allow_methods(AllowMethods::list([
1906                axum::http::Method::GET,
1907                axum::http::Method::POST,
1908            ]))
1909            .allow_headers(AllowHeaders::list([
1910                axum::http::header::AUTHORIZATION,
1911                axum::http::header::CONTENT_TYPE,
1912            ]))
1913    } else {
1914        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1915            let s = origin.to_str().unwrap_or("");
1916            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1917        }))
1918    }
1919}
1920
1921async fn add_security_headers(
1922    State(state): State<AppState>,
1923    mut req: Request<Body>,
1924    next: Next,
1925) -> Response {
1926    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1927    req.extensions_mut().insert(CspNonce(nonce.clone()));
1928    let mut resp = next.run(req).await;
1929    inject_page_fade_into_html(&mut resp, &nonce).await;
1930    let h = resp.headers_mut();
1931    // frame-ancestors defaults to deny (the UI cannot be iframed anywhere). An
1932    // operator can opt into embedding in named corporate dashboards by setting
1933    // SLOC_FRAME_ANCESTORS to a space-separated origin allowlist. X-Frame-Options
1934    // cannot express a multi-origin allowlist, so when one is configured we drop
1935    // XFO and let the CSP frame-ancestors directive govern (per-origin, and what
1936    // modern browsers honour); unset keeps the strict XFO: DENY + frame-ancestors
1937    // 'none' posture. A malformed value falls back to the safe default below.
1938    let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1939        .ok()
1940        .map(|v| v.trim().to_string())
1941        .filter(|v| !v.is_empty());
1942    if frame_ancestors.is_none() {
1943        h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1944    }
1945    let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1946    h.insert(
1947        "X-Content-Type-Options",
1948        HeaderValue::from_static("nosniff"),
1949    );
1950    h.insert(
1951        "Referrer-Policy",
1952        HeaderValue::from_static("strict-origin-when-cross-origin"),
1953    );
1954    let csp = format!(
1955        "default-src 'self'; \
1956         base-uri 'self'; \
1957         form-action 'self'; \
1958         style-src 'self' 'unsafe-inline'; \
1959         img-src 'self' data: blob:; \
1960         script-src 'self' 'nonce-{nonce}'; \
1961         font-src 'self' data:; \
1962         object-src 'none'; \
1963         frame-ancestors {frame_ancestors_directive}"
1964    );
1965    h.insert(
1966        "Content-Security-Policy",
1967        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1968            HeaderValue::from_static(
1969                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1970            )
1971        }),
1972    );
1973    h.insert(
1974        "X-Permitted-Cross-Domain-Policies",
1975        HeaderValue::from_static("none"),
1976    );
1977    h.insert(
1978        "Permissions-Policy",
1979        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1980    );
1981    h.insert(
1982        "Cross-Origin-Opener-Policy",
1983        HeaderValue::from_static("same-origin"),
1984    );
1985    h.insert(
1986        "Cross-Origin-Resource-Policy",
1987        HeaderValue::from_static("same-origin"),
1988    );
1989    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1990    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1991    h.insert(
1992        "Cross-Origin-Embedder-Policy",
1993        HeaderValue::from_static("require-corp"),
1994    );
1995    if state.tls_enabled {
1996        h.insert(
1997            "Strict-Transport-Security",
1998            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1999        );
2000    }
2001    resp
2002}
2003
2004/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
2005///
2006/// On state-changing methods, browser-driven cookie-authenticated requests must
2007/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
2008/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
2009///
2010/// Deliberately exempt:
2011/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
2012/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
2013///   ambient, so it is not CSRF-exploitable.
2014/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
2015/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
2016///   a browser performing a CSRF attack always sends `Origin`.
2017async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
2018    use axum::http::Method;
2019
2020    let is_state_changing = matches!(
2021        *req.method(),
2022        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
2023    );
2024    let path = req.uri().path();
2025    let has_token_auth = req.headers().contains_key("X-API-Key")
2026        || req
2027            .headers()
2028            .get(header::AUTHORIZATION)
2029            .and_then(|v| v.to_str().ok())
2030            .is_some_and(|v| v.starts_with("Bearer "));
2031
2032    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
2033        return next.run(req).await;
2034    }
2035
2036    let headers = req.headers();
2037    let header_str = |name: &header::HeaderName| {
2038        headers
2039            .get(name)
2040            .and_then(|v| v.to_str().ok())
2041            .map(str::to_owned)
2042    };
2043    let origin = header_str(&header::ORIGIN);
2044    let referer = header_str(&header::REFERER);
2045    let host = header_str(&header::HOST);
2046
2047    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
2048    let authority_of = |url: &str| -> Option<String> {
2049        url.split_once("://")
2050            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
2051    };
2052
2053    let source_authority = origin
2054        .as_deref()
2055        .and_then(authority_of)
2056        .or_else(|| referer.as_deref().and_then(authority_of));
2057
2058    match (source_authority, host) {
2059        // Neither Origin nor Referer present: treat as a non-browser client.
2060        (None, _) => next.run(req).await,
2061        (Some(src), Some(h)) if src == h => next.run(req).await,
2062        (Some(src), host) => {
2063            tracing::warn!(
2064                event = "csrf_rejected",
2065                path = %path,
2066                origin = %src,
2067                host = ?host,
2068                "Cross-origin state-changing request rejected (CSRF guard)"
2069            );
2070            (
2071                StatusCode::FORBIDDEN,
2072                "403 Forbidden — cross-origin request rejected\n",
2073            )
2074                .into_response()
2075        }
2076    }
2077}
2078
2079/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
2080/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
2081/// is overkill — a short opacity fade gives a smooth page-to-page transition
2082/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
2083/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
2084/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
2085/// light-mode flash for dark-theme users.
2086fn page_fade_html(nonce: &str) -> String {
2087    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
2088    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
2089    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
2090    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
2091    // class) keeps it invisible from the moment this style parses — at the top of <body> —
2092    // through the entire body parse, which reads as a delay before navigation "begins"
2093    // and then a blink. Without a fill-mode the animation starts at first paint and plays
2094    // 0 -> 1 cleanly, with no pre-paint hold.
2095    const STYLE: &str = r"<style>
2096@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
2097.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
2098body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
2099@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
2100</style>";
2101    // `dark`: apply the saved dark theme before paint to avoid a light flash.
2102    // The click handler gives immediate feedback by fading the *content* out the moment a
2103    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
2104    // preventDefault or delay navigation — the browser navigates instantly and the fade
2105    // plays opportunistically during the natural fetch window, so no latency is added.
2106    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
2107    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
2108    // if the click was actually a download (no unload) or the page is restored from bfcache.
2109    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');});})();";
2110    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
2111}
2112
2113/// Self-contained branded loading overlay for the heavy comparison pages (Scan
2114/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
2115/// `<script>` — meant to be spliced in immediately after `<body>`.
2116///
2117/// It pairs the spinner with a **visibility gate**: from the first byte the page
2118/// content is held at `visibility:hidden` (only the overlay paints), so the user
2119/// never sees a half-rendered flash while charts/tables are still settling. On
2120/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
2121/// still-opaque overlay, which then fades out one frame later — so the reveal is
2122/// of a finished page, with no glitch on either side of the transition.
2123///
2124/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
2125/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
2126/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
2127fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
2128    const TPL: &str = r#"<style nonce="__N__">
2129html.sloc-pending body{visibility:hidden;}
2130html.sloc-pending #rpt-loading-overlay{visibility:visible;}
2131#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%);}
2132#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
2133body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
2134body.pdf-mode #rpt-loading-overlay{display:none!important;}
2135.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
2136.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;}
2137.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;}
2138@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
2139@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
2140body.dark-theme .rpt-bg-blob{opacity:.36;}
2141.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;}
2142@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
2143body.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);}
2144.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
2145.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2146.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2147.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));}
2148@keyframes rpt-spin{to{transform:rotate(360deg);}}
2149.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;}
2150body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2151body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2152.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2153.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;}
2154@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2155.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2156.rpt-dot:nth-child(2){animation-delay:.28s;}
2157.rpt-dot:nth-child(3){animation-delay:.56s;}
2158@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2159.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2160.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2161.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;}
2162body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2163@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;}}
2164</style>
2165<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2166<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>
2167<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2168  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2169  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2170  <div class="rpt-load-card">
2171    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2172    <div class="rpt-spinner-wrap">
2173      <div class="rpt-spinner-track"></div>
2174      <div class="rpt-spinner"></div>
2175      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2176    </div>
2177    <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>
2178    <div class="rpt-status" id="rpt-status">__LABEL__</div>
2179    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2180  </div>
2181</div>
2182<script nonce="__N__">
2183(function(){
2184  var ov=document.getElementById('rpt-loading-overlay');
2185  var root=document.documentElement;
2186  function reveal(){root.classList.remove('sloc-pending');}
2187  if(!ov){reveal();return;}
2188  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2189  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2190  var mi=0,prog=0,done=false,start=Date.now();
2191  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2192  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2193  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2194  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2195  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2196  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2197  setProg(8);
2198  var msgTimer=setInterval(nextMsg,700);
2199  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);
2200  // These pages draw charts into known SVG containers that start empty and are
2201  // filled by JS once layout is available (some only after a ResizeObserver pass
2202  // post-`load`). Treat the page as ready only once every chart container present
2203  // actually has rendered content, so the overlay never lifts on a half-drawn page.
2204  function chartsRendered(){
2205    var sel=['#cmp-tl-svg','#mc-chart'];
2206    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2207    return true;
2208  }
2209  function finish(){
2210    if(done)return;done=true;
2211    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2212    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2213    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2214    reveal();
2215    requestAnimationFrame(function(){requestAnimationFrame(function(){
2216      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2217    });});
2218  }
2219  // Wait for `load` (resources + first layout), then poll until the charts have
2220  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2221  function afterLoad(){
2222    var loadAt=Date.now();
2223    (function poll(){
2224      if(done)return;
2225      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2226        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2227        return;
2228      }
2229      requestAnimationFrame(poll);
2230    })();
2231  }
2232  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2233  // Absolute safety net: never let the gate/overlay get stuck.
2234  setTimeout(function(){if(!done)finish();},HARD_CAP);
2235})();
2236</script>"#;
2237    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2238}
2239
2240/// Shared toast-notification assets + a global PDF-export helper, spliced into
2241/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
2242/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
2243/// placed just before `</body>`.
2244///
2245/// It defines two globals:
2246/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
2247///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
2248///   toast stays up until its returned handle's `.dismiss()` is called.
2249/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
2250///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
2251///   `/export/pdf`, triggers the download, then raises a success or error toast and
2252///   restores the button. Centralising this guarantees identical, obvious feedback
2253///   everywhere instead of a silent `alert()`-only failure path.
2254fn sloc_toast_assets(nonce: &str) -> String {
2255    const TPL: &str = r#"<style nonce="__N__">
2256#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;}
2257.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);}
2258.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2259.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2260.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;}
2261.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2262.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2263.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2264.sloc-toast-success{border-color:#bfe0cc;}
2265.sloc-toast-error{border-color:#e6b3b3;}
2266.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2267.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;}
2268@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2269.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;}
2270.sloc-toast-x:hover{opacity:1;}
2271body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2272body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2273body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2274body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2275@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2276</style>
2277<script nonce="__N__">
2278(function(){
2279  if(window.slocToast)return;
2280  function wrap(){
2281    var w=document.getElementById('sloc-toast-wrap');
2282    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);}
2283    return w;
2284  }
2285  window.slocToast=function(msg,opts){
2286    opts=opts||{};
2287    var type=opts.type||'info';
2288    var loading=type==='loading';
2289    var t=document.createElement('div');
2290    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2291    t.setAttribute('role',type==='error'?'alert':'status');
2292    var ico=loading
2293      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2294      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2295    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2296    t.querySelector('.sloc-toast-msg').textContent=String(msg);
2297    wrap().appendChild(t);
2298    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2299    var gone=false,timer=null;
2300    function close(){
2301      if(gone)return;gone=true;if(timer)clearTimeout(timer);
2302      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2303      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2304    }
2305    t.querySelector('.sloc-toast-x').addEventListener('click',close);
2306    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2307    if(ttl>0)timer=setTimeout(close,ttl);
2308    return {dismiss:close,el:t};
2309  };
2310  window.slocExportPdf=function(o){
2311    o=o||{};
2312    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2313    if(btn&&btn.disabled)return;
2314    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2315    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2316    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2317      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2318      .then(function(blob){
2319        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2320        document.body.appendChild(a);a.click();document.body.removeChild(a);
2321        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2322        load.dismiss();
2323        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2324      })
2325      .catch(function(e){
2326        load.dismiss();
2327        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2328      })
2329      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2330  };
2331})();
2332</script>"#;
2333    TPL.replace("__N__", nonce)
2334}
2335
2336/// Buffer an HTML response body and splice the page fade-in right after the
2337/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
2338/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
2339/// branded loading spinner for slow renders).
2340async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2341    let is_html = resp
2342        .headers()
2343        .get(header::CONTENT_TYPE)
2344        .and_then(|v| v.to_str().ok())
2345        .is_some_and(|v| v.starts_with("text/html"));
2346    if !is_html {
2347        return;
2348    }
2349    let body = std::mem::replace(resp.body_mut(), Body::empty());
2350    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2351        return;
2352    };
2353    let html = match String::from_utf8(bytes.to_vec()) {
2354        Ok(s) => s,
2355        Err(e) => {
2356            *resp.body_mut() = Body::from(e.into_bytes());
2357            return;
2358        }
2359    };
2360    if html.contains("id=\"rpt-loading-overlay\"") {
2361        *resp.body_mut() = Body::from(html);
2362        return;
2363    }
2364    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
2365    // avoids allocating a lowercased copy of the whole document on every request.
2366    // Fall back to a case-insensitive scan only if that fails (rare/never).
2367    let insert_at = html
2368        .find("<body")
2369        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2370        .or_else(|| {
2371            let lower = html.to_ascii_lowercase();
2372            lower
2373                .find("<body")
2374                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2375        });
2376    let new_html = match insert_at {
2377        Some(at) => {
2378            let mut out = String::with_capacity(html.len() + 1024);
2379            out.push_str(&html[..at]);
2380            out.push_str(&page_fade_html(nonce));
2381            out.push_str(&html[at..]);
2382            out
2383        }
2384        None => html,
2385    };
2386    resp.headers_mut().remove(header::CONTENT_LENGTH);
2387    *resp.body_mut() = Body::from(new_html);
2388}
2389
2390async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2391    let peer_ip = req
2392        .extensions()
2393        .get::<axum::extract::ConnectInfo<SocketAddr>>()
2394        .map(|c| c.0.ip());
2395
2396    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
2397    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
2398    // header spoofing from direct connections.
2399    let ip = peer_ip
2400        .and_then(|peer| {
2401            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2402                req.headers()
2403                    .get("X-Forwarded-For")
2404                    .and_then(|v| v.to_str().ok())
2405                    .and_then(|s| s.split(',').next())
2406                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
2407            } else {
2408                None
2409            }
2410        })
2411        .or(peer_ip)
2412        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2413
2414    if !state.rate_limiter.is_allowed(ip) {
2415        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2416            path = %req.uri().path(), "Rate limit exceeded");
2417        return (
2418            StatusCode::TOO_MANY_REQUESTS,
2419            [(header::RETRY_AFTER, "60")],
2420            "429 Too Many Requests\n",
2421        )
2422            .into_response();
2423    }
2424    next.run(req).await
2425}
2426
2427async fn splash(
2428    State(state): State<AppState>,
2429    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2430) -> impl IntoResponse {
2431    let lan_ip = if state.server_mode {
2432        primary_lan_ip()
2433    } else {
2434        None
2435    };
2436    let port = state
2437        .base_config
2438        .web
2439        .bind_address
2440        .rsplit(':')
2441        .next()
2442        .and_then(|p| p.parse::<u16>().ok())
2443        .unwrap_or(4317);
2444    let has_api_key = !state.api_keys.is_empty();
2445    let template = SplashTemplate {
2446        csp_nonce,
2447        server_mode: state.server_mode,
2448        lan_ip,
2449        port,
2450        version: env!("CARGO_PKG_VERSION"),
2451        has_api_key,
2452    };
2453    Html(
2454        template
2455            .render()
2456            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2457    )
2458}
2459
2460async fn index(
2461    State(state): State<AppState>,
2462    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2463    Query(query): Query<IndexQuery>,
2464) -> impl IntoResponse {
2465    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2466        let policy = query
2467            .mixed_line_policy
2468            .unwrap_or_else(|| "code_only".to_string());
2469        let behavior = query
2470            .binary_file_behavior
2471            .unwrap_or_else(|| "skip".to_string());
2472        let cfg = ScanConfig {
2473            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2474            path: query.path.unwrap_or_default(),
2475            include_globs: query.include_globs.unwrap_or_default(),
2476            exclude_globs: query.exclude_globs.unwrap_or_default(),
2477            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2478            mixed_line_policy: policy,
2479            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2480                != Some("off"),
2481            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2482            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2483            vendor_directory_detection: query.vendor_directory_detection.as_deref()
2484                != Some("disabled"),
2485            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2486            binary_file_behavior: behavior,
2487            output_dir: query.output_dir.unwrap_or_default(),
2488            report_title: query.report_title.unwrap_or_default(),
2489            continuation_line_policy: query
2490                .continuation_line_policy
2491                .unwrap_or_else(default_each_physical_line),
2492            blank_in_block_comment_policy: query
2493                .blank_in_block_comment_policy
2494                .unwrap_or_else(default_count_as_comment),
2495            count_compiler_directives: query.count_compiler_directives.as_deref()
2496                != Some("disabled"),
2497            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2498            style_col_threshold: query
2499                .style_col_threshold
2500                .as_deref()
2501                .and_then(|s| s.parse().ok())
2502                .unwrap_or(80),
2503            style_score_threshold: query
2504                .style_score_threshold
2505                .as_deref()
2506                .and_then(|s| s.parse().ok())
2507                .unwrap_or(0),
2508            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2509            coverage_file: query.coverage_file.unwrap_or_default(),
2510            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2511            complexity_alert: query
2512                .complexity_alert
2513                .as_deref()
2514                .and_then(|s| s.parse().ok())
2515                .unwrap_or(0),
2516            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2517            activity_window: query
2518                .activity_window
2519                .as_deref()
2520                .and_then(|s| s.parse().ok())
2521                .unwrap_or(90),
2522        };
2523        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2524    } else {
2525        "{}".to_string()
2526    };
2527
2528    let git_repo = query.git_repo.unwrap_or_default();
2529    let git_ref = query.git_ref.unwrap_or_default();
2530
2531    let git_label = make_git_label(&git_repo, &git_ref);
2532    let git_output_dir = if git_label.is_empty() {
2533        String::new()
2534    } else {
2535        desktop_dir().join(&git_label).display().to_string()
2536    };
2537    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2538    let git_output_dir_json =
2539        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2540
2541    let template = IndexTemplate {
2542        version: env!("CARGO_PKG_VERSION"),
2543        prefill_json,
2544        csp_nonce,
2545        git_repo,
2546        git_ref,
2547        git_label_json,
2548        git_output_dir_json,
2549        server_mode: state.server_mode,
2550    };
2551
2552    Html(
2553        template
2554            .render()
2555            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2556    )
2557}
2558
2559async fn scan_setup_handler(
2560    State(state): State<AppState>,
2561    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2562) -> impl IntoResponse {
2563    let recent_scans_json = {
2564        let arr: Vec<serde_json::Value> = {
2565            let reg = state.registry.lock().await;
2566            reg.entries
2567                .iter()
2568                .rev()
2569                .take(6)
2570                .map(|e| {
2571                    let run_dir = e
2572                        .html_path
2573                        .as_ref()
2574                        .or(e.json_path.as_ref())
2575                        .and_then(|p| p.parent().map(PathBuf::from));
2576                    let config_val: Option<serde_json::Value> = run_dir
2577                        .and_then(|d| find_scan_config_in_dir(&d))
2578                        .and_then(|p| fs::read_to_string(&p).ok())
2579                        .and_then(|s| serde_json::from_str(&s).ok());
2580                    serde_json::json!({
2581                        "project_label": e.project_label,
2582                        "timestamp": fmt_la_time(e.timestamp_utc),
2583                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2584                        "config": config_val,
2585                    })
2586                })
2587                .collect()
2588        };
2589        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2590    };
2591
2592    let template = ScanSetupTemplate {
2593        version: env!("CARGO_PKG_VERSION"),
2594        recent_scans_json,
2595        csp_nonce,
2596    };
2597    Html(
2598        template
2599            .render()
2600            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2601    )
2602}
2603
2604/// Build provenance embedded at compile time by `build.rs`. Falls back to
2605/// "unknown" on air-gapped builds with no git available.
2606const GIT_SHA: &str = env!("OXIDE_SLOC_GIT_SHA");
2607const BUILD_TIME: &str = env!("OXIDE_SLOC_BUILD_TIME");
2608
2609/// Process start instant, anchored the first time it is read. Called once during
2610/// `serve()` startup so uptime is measured from launch, not from the first probe.
2611pub(crate) fn process_start() -> std::time::Instant {
2612    static START: OnceLock<std::time::Instant> = OnceLock::new();
2613    *START.get_or_init(std::time::Instant::now)
2614}
2615
2616fn uptime_seconds() -> u64 {
2617    process_start().elapsed().as_secs()
2618}
2619
2620/// Liveness probe — the process is up and the event loop is servicing requests.
2621/// Deliberately trivial and dependency-free so container/systemd probes stay fast
2622/// and stable. Readiness (dependency health) is `/readyz`; rich status is `/api/health`.
2623async fn healthz() -> &'static str {
2624    "ok"
2625}
2626
2627/// Probe whether a directory is writable by round-tripping a tiny marker file.
2628/// An empty path is treated as writable (nothing to check).
2629fn dir_writable(dir: &std::path::Path) -> bool {
2630    if dir.as_os_str().is_empty() {
2631        return true;
2632    }
2633    let _ = std::fs::create_dir_all(dir);
2634    let probe = dir.join(".oxide-sloc-health-probe");
2635    match std::fs::write(&probe, b"") {
2636        Ok(()) => {
2637            let _ = std::fs::remove_file(&probe);
2638            true
2639        }
2640        Err(_) => false,
2641    }
2642}
2643
2644/// Dependency health checks backing `/api/health` and `/readyz`: can we persist
2645/// the registry and write scan artifacts? Returned in stable order.
2646fn health_checks(state: &AppState) -> Vec<(&'static str, bool)> {
2647    let registry_dir = state
2648        .registry_path
2649        .parent()
2650        .map_or_else(|| std::path::Path::new("."), |p| p);
2651    vec![
2652        ("registry_writable", dir_writable(registry_dir)),
2653        (
2654            "output_dir_writable",
2655            dir_writable(&resolve_output_root(None)),
2656        ),
2657    ]
2658}
2659
2660fn checks_to_json(checks: &[(&'static str, bool)]) -> serde_json::Value {
2661    let map: serde_json::Map<String, serde_json::Value> = checks
2662        .iter()
2663        .map(|(k, v)| ((*k).to_owned(), serde_json::Value::Bool(*v)))
2664        .collect();
2665    serde_json::Value::Object(map)
2666}
2667
2668/// Structured health/status endpoint (`/api/health`). Always answers 200 when the
2669/// process is responsive; the `status` field is `"ok"` when every dependency check
2670/// passes and `"degraded"` otherwise. Use `/readyz` for a pass/fail readiness gate.
2671async fn api_health_handler(State(state): State<AppState>) -> impl IntoResponse {
2672    let checks = health_checks(&state);
2673    let all_ok = checks.iter().all(|(_, ok)| *ok);
2674    axum::Json(serde_json::json!({
2675        "status": if all_ok { "ok" } else { "degraded" },
2676        "name": "oxide-sloc",
2677        "version": env!("CARGO_PKG_VERSION"),
2678        "git_sha": GIT_SHA,
2679        "build_time": BUILD_TIME,
2680        "uptime_seconds": uptime_seconds(),
2681        "checks": checks_to_json(&checks),
2682    }))
2683}
2684
2685/// Readiness probe (`/readyz`): 200 when the server can persist state and write
2686/// artifacts, 503 otherwise. Distinct from `/healthz` (liveness) so orchestrators
2687/// can hold traffic off a process that is up but unable to serve real work.
2688async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
2689    let checks = health_checks(&state);
2690    let ready = checks.iter().all(|(_, ok)| *ok);
2691    let code = if ready {
2692        axum::http::StatusCode::OK
2693    } else {
2694        axum::http::StatusCode::SERVICE_UNAVAILABLE
2695    };
2696    (
2697        code,
2698        axum::Json(serde_json::json!({
2699            "status": if ready { "ready" } else { "not_ready" },
2700            "checks": checks_to_json(&checks),
2701        })),
2702    )
2703}
2704
2705async fn api_version_handler() -> impl IntoResponse {
2706    axum::Json(serde_json::json!({
2707        "name": "oxide-sloc",
2708        "version": env!("CARGO_PKG_VERSION"),
2709        "git_sha": GIT_SHA,
2710        "build_time": BUILD_TIME,
2711    }))
2712}
2713
2714// ── Prometheus metrics ────────────────────────────────────────────────────────
2715
2716fn prom_runs_total() -> &'static prometheus::IntCounter {
2717    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2718    COUNTER.get_or_init(|| {
2719        prometheus::register_int_counter!(
2720            "oxide_sloc_runs_total",
2721            "Total number of completed analysis runs"
2722        )
2723        .expect("failed to register oxide_sloc_runs_total counter")
2724    })
2725}
2726
2727async fn metrics_handler() -> impl IntoResponse {
2728    use prometheus::Encoder as _;
2729    let mut buf = Vec::new();
2730    let encoder = prometheus::TextEncoder::new();
2731    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2732    (
2733        [(
2734            axum::http::header::CONTENT_TYPE,
2735            "text/plain; version=0.0.4; charset=utf-8",
2736        )],
2737        buf,
2738    )
2739}
2740
2741static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2742
2743async fn openapi_yaml_handler() -> impl IntoResponse {
2744    (
2745        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2746        OPENAPI_YAML,
2747    )
2748}
2749
2750static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2751static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2752
2753async fn llms_txt_handler() -> impl IntoResponse {
2754    (
2755        [
2756            (
2757                axum::http::header::CONTENT_TYPE,
2758                "text/plain; charset=utf-8",
2759            ),
2760            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2761        ],
2762        LLMS_TXT,
2763    )
2764}
2765
2766async fn llms_full_txt_handler() -> impl IntoResponse {
2767    (
2768        [
2769            (
2770                axum::http::header::CONTENT_TYPE,
2771                "text/plain; charset=utf-8",
2772            ),
2773            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2774        ],
2775        LLMS_FULL_TXT,
2776    )
2777}
2778
2779async fn api_docs_handler(
2780    State(state): State<AppState>,
2781    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2782) -> impl IntoResponse {
2783    let has_api_key = !state.api_keys.is_empty();
2784    Html(
2785        ApiDocsTemplate {
2786            has_api_key,
2787            csp_nonce,
2788            version: env!("CARGO_PKG_VERSION"),
2789        }
2790        .render()
2791        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2792    )
2793}
2794
2795async fn chart_js_handler() -> impl IntoResponse {
2796    (
2797        [
2798            (
2799                header::CONTENT_TYPE,
2800                "application/javascript; charset=utf-8",
2801            ),
2802            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2803        ],
2804        CHART_JS,
2805    )
2806}
2807
2808async fn report_chart_js_handler() -> impl IntoResponse {
2809    (
2810        [
2811            (
2812                header::CONTENT_TYPE,
2813                "application/javascript; charset=utf-8",
2814            ),
2815            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2816        ],
2817        REPORT_CHART_JS,
2818    )
2819}
2820
2821#[derive(Debug, Deserialize)]
2822struct AnalyzeForm {
2823    path: String,
2824    git_repo: Option<String>,
2825    git_ref: Option<String>,
2826    mixed_line_policy: Option<MixedLinePolicy>,
2827    python_docstrings_as_comments: Option<String>,
2828    generated_file_detection: Option<String>,
2829    minified_file_detection: Option<String>,
2830    vendor_directory_detection: Option<String>,
2831    include_lockfiles: Option<String>,
2832    binary_file_behavior: Option<BinaryFileBehavior>,
2833    output_dir: Option<String>,
2834    report_title: Option<String>,
2835    report_header_footer: Option<String>,
2836    include_globs: Option<String>,
2837    exclude_globs: Option<String>,
2838    submodule_breakdown: Option<String>,
2839    coverage_file: Option<String>,
2840    continuation_line_policy: Option<ContinuationLinePolicy>,
2841    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2842    count_compiler_directives: Option<String>,
2843    style_col_threshold: Option<String>,
2844    style_analysis_enabled: Option<String>,
2845    style_score_threshold: Option<String>,
2846    style_lang_scope: Option<String>,
2847    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2848    cocomo_mode: Option<String>,
2849    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2850    complexity_alert: Option<String>,
2851    /// Whether to exclude duplicate files from displayed SLOC totals.
2852    exclude_duplicates: Option<String>,
2853    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2854    activity_window: Option<String>,
2855}
2856
2857#[allow(clippy::struct_excessive_bools)]
2858#[derive(Debug, Serialize, Deserialize, Clone)]
2859struct ScanConfig {
2860    oxide_sloc_version: String,
2861    path: String,
2862    include_globs: String,
2863    exclude_globs: String,
2864    submodule_breakdown: bool,
2865    mixed_line_policy: String,
2866    python_docstrings_as_comments: bool,
2867    generated_file_detection: bool,
2868    minified_file_detection: bool,
2869    vendor_directory_detection: bool,
2870    include_lockfiles: bool,
2871    binary_file_behavior: String,
2872    output_dir: String,
2873    report_title: String,
2874    // IEEE 1045-1992 and advanced fields added in later release
2875    #[serde(default = "default_each_physical_line")]
2876    continuation_line_policy: String,
2877    #[serde(default = "default_count_as_comment")]
2878    blank_in_block_comment_policy: String,
2879    #[serde(default = "default_true_bool")]
2880    count_compiler_directives: bool,
2881    #[serde(default = "default_true_bool")]
2882    style_analysis_enabled: bool,
2883    #[serde(default = "default_style_col_threshold")]
2884    style_col_threshold: u16,
2885    #[serde(default)]
2886    style_score_threshold: u8,
2887    #[serde(default = "default_all_scope")]
2888    style_lang_scope: String,
2889    #[serde(default)]
2890    coverage_file: String,
2891    #[serde(default = "default_organic")]
2892    cocomo_mode: String,
2893    #[serde(default)]
2894    complexity_alert: u32,
2895    #[serde(default)]
2896    exclude_duplicates: bool,
2897    /// Git hotspots activity window in days (on by default; 0 = disabled).
2898    #[serde(default = "default_activity_window")]
2899    activity_window: u32,
2900}
2901
2902const fn default_activity_window() -> u32 {
2903    90
2904}
2905
2906fn default_each_physical_line() -> String {
2907    "each_physical_line".to_string()
2908}
2909fn default_count_as_comment() -> String {
2910    "count_as_comment".to_string()
2911}
2912const fn default_true_bool() -> bool {
2913    true
2914}
2915const fn default_style_col_threshold() -> u16 {
2916    80
2917}
2918fn default_all_scope() -> String {
2919    "all".to_string()
2920}
2921fn default_organic() -> String {
2922    "organic".to_string()
2923}
2924
2925#[derive(Debug, Deserialize, Default)]
2926struct IndexQuery {
2927    path: Option<String>,
2928    include_globs: Option<String>,
2929    exclude_globs: Option<String>,
2930    submodule_breakdown: Option<String>,
2931    mixed_line_policy: Option<String>,
2932    python_docstrings_as_comments: Option<String>,
2933    generated_file_detection: Option<String>,
2934    minified_file_detection: Option<String>,
2935    vendor_directory_detection: Option<String>,
2936    include_lockfiles: Option<String>,
2937    binary_file_behavior: Option<String>,
2938    output_dir: Option<String>,
2939    report_title: Option<String>,
2940    prefilled: Option<String>,
2941    git_repo: Option<String>,
2942    git_ref: Option<String>,
2943    // IEEE 1045-1992 and advanced fields
2944    continuation_line_policy: Option<String>,
2945    blank_in_block_comment_policy: Option<String>,
2946    count_compiler_directives: Option<String>,
2947    style_analysis_enabled: Option<String>,
2948    style_col_threshold: Option<String>,
2949    style_score_threshold: Option<String>,
2950    style_lang_scope: Option<String>,
2951    coverage_file: Option<String>,
2952    cocomo_mode: Option<String>,
2953    complexity_alert: Option<String>,
2954    exclude_duplicates: Option<String>,
2955    activity_window: Option<String>,
2956}
2957
2958#[derive(Debug, Deserialize)]
2959struct PreviewQuery {
2960    path: Option<String>,
2961    include_globs: Option<String>,
2962    exclude_globs: Option<String>,
2963}
2964
2965#[cfg(feature = "native-dialog")]
2966#[derive(Debug, Deserialize)]
2967struct PickDirectoryQuery {
2968    kind: Option<String>,
2969    current: Option<String>,
2970}
2971
2972#[cfg(not(feature = "native-dialog"))]
2973#[derive(Debug, Deserialize)]
2974struct PickDirectoryQuery {}
2975
2976#[derive(Debug, Deserialize, Default)]
2977struct ArtifactQuery {
2978    download: Option<String>,
2979}
2980
2981#[cfg(feature = "native-dialog")]
2982#[derive(Debug, Serialize)]
2983struct PickDirectoryResponse {
2984    selected_path: Option<String>,
2985    cancelled: bool,
2986}
2987
2988#[cfg(feature = "native-dialog")]
2989async fn pick_directory_handler(
2990    State(state): State<AppState>,
2991    Query(query): Query<PickDirectoryQuery>,
2992) -> Response {
2993    if state.server_mode {
2994        return StatusCode::NOT_FOUND.into_response();
2995    }
2996    // Return immediately without opening a dialog in headless / CI environments.
2997    if std::env::var("SLOC_HEADLESS").is_ok() {
2998        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2999            .into_response();
3000    }
3001
3002    let is_coverage = query.kind.as_deref() == Some("coverage");
3003    let title = match query.kind.as_deref() {
3004        Some("output") => "Select output directory",
3005        Some("reports") => "Select folder containing saved reports",
3006        Some("coverage") => "Select LCOV coverage file",
3007        _ => "Select project directory",
3008    }
3009    .to_owned();
3010    let current = query.current.clone();
3011
3012    let picked = tokio::task::spawn_blocking(move || {
3013        // Windows: attach to the foreground thread so the dialog inherits focus,
3014        // and kick off a watcher that flashes the dialog once it appears.
3015        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3016        let fg_tid = win_dialog_focus::attach_to_foreground();
3017        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3018        win_dialog_focus::flash_dialog_when_ready(title.clone());
3019
3020        let mut dialog = rfd::FileDialog::new().set_title(&title);
3021        if let Some(current) = current.as_deref() {
3022            let resolved = resolve_input_path(current);
3023            let seed = if resolved.is_dir() {
3024                Some(resolved)
3025            } else {
3026                resolved.parent().map(Path::to_path_buf)
3027            };
3028            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
3029                dialog = dialog.set_directory(seed_dir);
3030            }
3031        }
3032        let result = if is_coverage {
3033            dialog
3034                .add_filter(
3035                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
3036                    &["info", "lcov", "xml", "json"],
3037                )
3038                .pick_file()
3039        } else {
3040            dialog.pick_folder()
3041        };
3042
3043        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3044        win_dialog_focus::detach_from_foreground(fg_tid);
3045
3046        result
3047    })
3048    .await
3049    .unwrap_or(None);
3050
3051    Json(PickDirectoryResponse {
3052        selected_path: picked.as_ref().map(|p| display_path(p)),
3053        cancelled: picked.is_none(),
3054    })
3055    .into_response()
3056}
3057
3058#[cfg(not(feature = "native-dialog"))]
3059async fn pick_directory_handler(
3060    State(_state): State<AppState>,
3061    Query(_query): Query<PickDirectoryQuery>,
3062) -> Response {
3063    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3064}
3065
3066#[cfg(feature = "native-dialog")]
3067async fn pick_file_handler(State(state): State<AppState>) -> Response {
3068    if state.server_mode {
3069        return StatusCode::NOT_FOUND.into_response();
3070    }
3071    if std::env::var("SLOC_HEADLESS").is_ok() {
3072        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
3073            .into_response();
3074    }
3075    let picked = tokio::task::spawn_blocking(|| {
3076        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3077        let fg_tid = win_dialog_focus::attach_to_foreground();
3078        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3079        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
3080
3081        let result = rfd::FileDialog::new()
3082            .set_title("Select HTML report")
3083            .add_filter("HTML report", &["html"])
3084            .pick_file();
3085
3086        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
3087        win_dialog_focus::detach_from_foreground(fg_tid);
3088
3089        result
3090    })
3091    .await
3092    .unwrap_or(None);
3093    Json(PickDirectoryResponse {
3094        selected_path: picked.as_ref().map(|p| display_path(p)),
3095        cancelled: picked.is_none(),
3096    })
3097    .into_response()
3098}
3099
3100#[cfg(not(feature = "native-dialog"))]
3101async fn pick_file_handler(State(_state): State<AppState>) -> Response {
3102    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
3103}
3104
3105// ── Browser-upload handlers (server mode only) ────────────────────────────────
3106
3107/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
3108/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
3109fn is_upload_tmp_path(path: &Path) -> bool {
3110    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
3111    path.starts_with(&upload_root)
3112}
3113
3114/// Returns true when `path` is the built-in sample or test-fixture directory.
3115/// These paths ship with the server binary and are always safe to scan/preview.
3116fn is_sample_path(path: &Path) -> bool {
3117    let root = workspace_root();
3118    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
3119}
3120
3121/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
3122fn upload_base_dir() -> PathBuf {
3123    std::env::temp_dir().join("oxide-sloc-uploads")
3124}
3125
3126/// Returns the staging path for a given upload id inside the base dir.
3127fn upload_staging_path(id: &str) -> PathBuf {
3128    upload_base_dir().join(id)
3129}
3130
3131/// Validate basic field constraints on a directory-upload request.
3132/// Returns an error `Response` if the request should be rejected immediately.
3133#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3134fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
3135    const MAX_FILES: usize = 50_000;
3136    if body.files.is_empty() {
3137        return Err((
3138            StatusCode::BAD_REQUEST,
3139            Json(serde_json::json!({"error": "No files received"})),
3140        )
3141            .into_response());
3142    }
3143    if body.files.len() > MAX_FILES {
3144        return Err((
3145            StatusCode::PAYLOAD_TOO_LARGE,
3146            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
3147        )
3148            .into_response());
3149    }
3150    Ok(())
3151}
3152
3153/// Resolve or create the staging directory for a directory upload.
3154/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
3155fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
3156    match id {
3157        Some(id)
3158            if !id.is_empty()
3159                && id.len() <= 36
3160                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
3161        {
3162            (id.to_string(), upload_staging_path(id))
3163        }
3164        _ => {
3165            let new_id = uuid::Uuid::new_v4().to_string();
3166            let staging = upload_staging_path(&new_id);
3167            (new_id, staging)
3168        }
3169    }
3170}
3171
3172/// Decode, size-check, and write one uploaded file entry into `staging`.
3173/// Returns `Ok(())` whether the file was written or skipped (bad base64).
3174/// Returns `Err(Response)` for fatal errors; the caller is responsible for
3175/// cleaning up `staging` before propagating the error.
3176#[allow(clippy::result_large_err)]
3177async fn stage_decoded_entry(
3178    entry: &UploadedFile,
3179    staging: &Path,
3180    total_bytes: &mut usize,
3181    project_root: &mut Option<PathBuf>,
3182) -> Result<(), Response> {
3183    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
3184
3185    let Ok(data) = base64::Engine::decode(
3186        &base64::engine::general_purpose::STANDARD,
3187        entry.content.as_bytes(),
3188    ) else {
3189        return Ok(());
3190    };
3191
3192    *total_bytes += data.len();
3193    if *total_bytes > MAX_TOTAL_BYTES {
3194        return Err((
3195            StatusCode::PAYLOAD_TOO_LARGE,
3196            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
3197        )
3198            .into_response());
3199    }
3200
3201    let rel = std::path::Path::new(&entry.path);
3202    if project_root.is_none()
3203        && let Some(first) = rel.components().next()
3204    {
3205        *project_root = Some(staging.join(first.as_os_str()));
3206    }
3207
3208    let dest = staging.join(rel);
3209    if let Some(parent) = dest.parent()
3210        && tokio::fs::create_dir_all(parent).await.is_err()
3211    {
3212        return Err((
3213            StatusCode::INTERNAL_SERVER_ERROR,
3214            Json(serde_json::json!({"error": "Failed to create directory structure"})),
3215        )
3216            .into_response());
3217    }
3218
3219    if tokio::fs::write(&dest, &data).await.is_err() {
3220        return Err((
3221            StatusCode::INTERNAL_SERVER_ERROR,
3222            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3223        )
3224            .into_response());
3225    }
3226
3227    Ok(())
3228}
3229
3230/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
3231/// and path-traversal guard. Returns `(file_count, project_root)` on success or
3232/// an error `Response` on failure (staging dir is cleaned up before returning).
3233async fn write_upload_files(
3234    files: &[UploadedFile],
3235    staging: &Path,
3236    upload_id: &str,
3237) -> Result<(usize, Option<PathBuf>), Response> {
3238    let mut total_bytes: usize = 0;
3239    let mut project_root: Option<PathBuf> = None;
3240
3241    for entry in files {
3242        let rel = std::path::Path::new(&entry.path);
3243        if rel
3244            .components()
3245            .any(|c| matches!(c, std::path::Component::ParentDir))
3246        {
3247            // Reject the entire upload on the first path traversal attempt.
3248            let _ = tokio::fs::remove_dir_all(staging).await;
3249            tracing::warn!(
3250                event = "upload_path_traversal",
3251                upload_id = %upload_id,
3252                path = %entry.path,
3253                "Upload rejected: path traversal component detected"
3254            );
3255            return Err((
3256                StatusCode::BAD_REQUEST,
3257                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3258            )
3259                .into_response());
3260        }
3261
3262        if let Err(resp) =
3263            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3264        {
3265            let _ = tokio::fs::remove_dir_all(staging).await;
3266            return Err(resp);
3267        }
3268    }
3269
3270    Ok((files.len(), project_root))
3271}
3272
3273/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
3274/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
3275fn parse_tarball_size_caps() -> (u64, u64) {
3276    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3277        .ok()
3278        .and_then(|v| v.parse().ok())
3279        .unwrap_or(2048_u64)
3280        * 1024
3281        * 1024;
3282    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3283        .ok()
3284        .and_then(|v| v.parse().ok())
3285        .unwrap_or(10_240_u64)
3286        * 1024
3287        * 1024;
3288    (compressed, decompressed)
3289}
3290
3291/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
3292/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
3293/// are rejected before the streaming handler is invoked.
3294fn tarball_http_body_limit_bytes() -> usize {
3295    std::env::var("SLOC_MAX_TARBALL_MB")
3296        .ok()
3297        .and_then(|v| v.parse::<usize>().ok())
3298        .unwrap_or(2048)
3299        .saturating_mul(1024 * 1024)
3300}
3301
3302/// Stream `body` into `dest_path`, enforcing `max_bytes`.
3303/// Returns the number of compressed bytes written, or an error `Response`.
3304/// Cleans up `dest_path` on error.
3305#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3306async fn stream_body_to_file(
3307    body: axum::body::Body,
3308    dest_path: &Path,
3309    max_bytes: u64,
3310) -> Result<u64, Response> {
3311    use http_body_util::BodyExt as _;
3312    use tokio::io::AsyncWriteExt as _;
3313
3314    let mut file = match tokio::fs::File::create(dest_path).await {
3315        Ok(f) => f,
3316        Err(e) => {
3317            tracing::error!(
3318                event = "upload_io_error",
3319                "failed to create tarball temp file: {e}"
3320            );
3321            return Err((
3322                StatusCode::INTERNAL_SERVER_ERROR,
3323                Json(serde_json::json!({"error": "Upload initialization failed"})),
3324            )
3325                .into_response());
3326        }
3327    };
3328
3329    let mut body = body;
3330    let mut written: u64 = 0;
3331    loop {
3332        match body.frame().await {
3333            None => break,
3334            Some(Err(e)) => {
3335                let _ = tokio::fs::remove_file(dest_path).await;
3336                return Err((
3337                    StatusCode::BAD_REQUEST,
3338                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3339                )
3340                    .into_response());
3341            }
3342            Some(Ok(frame)) => {
3343                if let Ok(data) = frame.into_data() {
3344                    written += data.len() as u64;
3345                    if written > max_bytes {
3346                        let _ = tokio::fs::remove_file(dest_path).await;
3347                        return Err((
3348                            StatusCode::PAYLOAD_TOO_LARGE,
3349                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3350                        )
3351                            .into_response());
3352                    }
3353                    if let Err(e) = file.write_all(&data).await {
3354                        let _ = tokio::fs::remove_file(dest_path).await;
3355                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3356                        return Err((
3357                            StatusCode::INTERNAL_SERVER_ERROR,
3358                            Json(serde_json::json!({"error": "Upload write failed"})),
3359                        )
3360                            .into_response());
3361                    }
3362                }
3363            }
3364        }
3365    }
3366    drop(file);
3367    Ok(written)
3368}
3369
3370/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
3371/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
3372/// on failure (staging dir is cleaned up before returning).
3373#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3374async fn extract_tarball_to_staging(
3375    tarball_path: &Path,
3376    staging: &Path,
3377    max_decompressed_bytes: u64,
3378) -> Result<(), Response> {
3379    let staging_clone = staging.to_path_buf();
3380    let tarball_clone = tarball_path.to_path_buf();
3381    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3382        let file = std::fs::File::open(&tarball_clone)?;
3383        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3384        let limited = SizeLimitReader {
3385            inner: gz,
3386            remaining: max_decompressed_bytes,
3387        };
3388        let mut archive = tar::Archive::new(limited);
3389        archive.set_overwrite(true);
3390        archive.set_preserve_permissions(false);
3391        std::fs::create_dir_all(&staging_clone)?;
3392        archive.unpack(&staging_clone)?;
3393        Ok(())
3394    })
3395    .await;
3396    let _ = tokio::fs::remove_file(tarball_path).await;
3397
3398    match extract_result {
3399        Ok(Ok(())) => Ok(()),
3400        Ok(Err(e)) => {
3401            let _ = tokio::fs::remove_dir_all(staging).await;
3402            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3403            tracing::warn!(
3404                event = "upload_extract_error",
3405                "tarball extraction failed: {e:#}"
3406            );
3407            let (status, msg) = if is_size_limit {
3408                (
3409                    StatusCode::PAYLOAD_TOO_LARGE,
3410                    "Archive exceeds the decompressed size limit",
3411                )
3412            } else {
3413                (StatusCode::BAD_REQUEST, "Failed to extract archive")
3414            };
3415            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3416        }
3417        Err(e) => {
3418            let _ = tokio::fs::remove_dir_all(staging).await;
3419            tracing::error!(
3420                event = "upload_extract_panic",
3421                "tarball extraction task panicked: {e}"
3422            );
3423            Err((
3424                StatusCode::INTERNAL_SERVER_ERROR,
3425                Json(serde_json::json!({"error": "Archive extraction failed"})),
3426            )
3427                .into_response())
3428        }
3429    }
3430}
3431
3432/// If `staging` contains exactly one top-level directory, return its path
3433/// (the common case when the archive was created with `webkitRelativePath`).
3434/// Otherwise return `None`.
3435async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3436    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3437    let first = entries.next_entry().await.ok()??;
3438    if !first.path().is_dir() {
3439        return None;
3440    }
3441    if entries.next_entry().await.unwrap_or(None).is_some() {
3442        return None;
3443    }
3444    Some(first.path())
3445}
3446
3447/// Request body for `POST /api/upload-directory`.
3448///
3449/// Each entry carries a relative path (identical to the browser's
3450/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
3451/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
3452/// avoids pulling in a `multipart` library that is not in the vendor archive.
3453#[derive(Deserialize)]
3454struct UploadDirRequest {
3455    files: Vec<UploadedFile>,
3456    /// If provided, append this batch to an existing upload session instead of
3457    /// creating a new staging directory. Must be a plain UUID (no path separators).
3458    upload_id: Option<String>,
3459}
3460
3461#[derive(Deserialize)]
3462struct UploadedFile {
3463    /// `webkitRelativePath` value from the browser File object.
3464    path: String,
3465    /// Raw file bytes encoded as standard base64.
3466    content: String,
3467}
3468
3469/// POST /api/upload-directory
3470///
3471/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
3472/// Saves all files to a temp staging directory preserving their relative paths,
3473/// then returns the server-side root directory path so the caller can populate
3474/// the scan-path field and run a normal analysis.
3475///
3476/// Only available in server mode; returns 404 in local mode (use the native
3477/// rfd dialog instead).
3478async fn upload_directory_handler(
3479    State(state): State<AppState>,
3480    Json(body): Json<UploadDirRequest>,
3481) -> Response {
3482    if !state.server_mode {
3483        return StatusCode::NOT_FOUND.into_response();
3484    }
3485    if let Err(resp) = validate_upload_dir_request(&body) {
3486        return resp;
3487    }
3488    // Reuse an existing staging dir when the client sends a continuation batch,
3489    // otherwise create a fresh one. Validate the id to prevent path traversal.
3490    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3491    match write_upload_files(&body.files, &staging, &upload_id).await {
3492        Ok((file_count, project_root)) => {
3493            let scan_root = project_root.unwrap_or_else(|| staging.clone());
3494            Json(serde_json::json!({
3495                "tmp_path": scan_root.to_string_lossy(),
3496                "file_count": file_count,
3497                "upload_id": upload_id.clone()
3498            }))
3499            .into_response()
3500        }
3501        Err(resp) => resp,
3502    }
3503}
3504
3505/// Request body for `POST /api/upload-file`.
3506#[derive(Deserialize)]
3507struct UploadFileRequest {
3508    /// Original filename (used only to preserve the extension).
3509    filename: String,
3510    /// File bytes encoded as standard base64.
3511    content: String,
3512}
3513
3514/// POST /api/upload-file
3515///
3516/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
3517/// Accepts `{ "filename": "…", "content": "<base64>" }`.
3518/// Only available in server mode.
3519async fn upload_file_handler(
3520    State(state): State<AppState>,
3521    Json(body): Json<UploadFileRequest>,
3522) -> Response {
3523    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
3524
3525    if !state.server_mode {
3526        return StatusCode::NOT_FOUND.into_response();
3527    }
3528
3529    let Ok(data) = base64::Engine::decode(
3530        &base64::engine::general_purpose::STANDARD,
3531        body.content.as_bytes(),
3532    ) else {
3533        return (
3534            StatusCode::BAD_REQUEST,
3535            Json(serde_json::json!({"error": "Invalid base64 content"})),
3536        )
3537            .into_response();
3538    };
3539
3540    if data.len() > MAX_FILE_BYTES {
3541        return (
3542            StatusCode::PAYLOAD_TOO_LARGE,
3543            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3544        )
3545            .into_response();
3546    }
3547
3548    // Sanitise: strip any directory component from the filename.
3549    let filename = std::path::Path::new(&body.filename)
3550        .file_name()
3551        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3552
3553    let upload_id = uuid::Uuid::new_v4();
3554    let staging = std::env::temp_dir()
3555        .join("oxide-sloc-uploads")
3556        .join(upload_id.to_string());
3557
3558    if tokio::fs::create_dir_all(&staging).await.is_err() {
3559        return (
3560            StatusCode::INTERNAL_SERVER_ERROR,
3561            Json(serde_json::json!({"error": "Failed to create staging directory"})),
3562        )
3563            .into_response();
3564    }
3565
3566    let dest = staging.join(&filename);
3567    if tokio::fs::write(&dest, &data).await.is_err() {
3568        let _ = tokio::fs::remove_dir_all(&staging).await;
3569        return (
3570            StatusCode::INTERNAL_SERVER_ERROR,
3571            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3572        )
3573            .into_response();
3574    }
3575
3576    Json(serde_json::json!({
3577        "tmp_path": dest.to_string_lossy(),
3578        "upload_id": upload_id.to_string()
3579    }))
3580    .into_response()
3581}
3582
3583/// POST /api/upload-tarball
3584///
3585/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
3586/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
3587/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
3588/// project root. The two size fields power the "Original / Compressed project size" display in the
3589/// web UI.
3590///
3591/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
3592/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
3593/// cap during decompression. The browser-side JS creates the archive one file at a time using
3594/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
3595/// project size.
3596/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
3597/// decompressed. Wraps any `std::io::Read` source.
3598struct SizeLimitReader<R> {
3599    inner: R,
3600    remaining: u64,
3601}
3602impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3603    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3604        if self.remaining == 0 {
3605            return Err(std::io::Error::other("decompressed size limit exceeded"));
3606        }
3607        let n = self.inner.read(buf)?;
3608        self.remaining = self.remaining.saturating_sub(n as u64);
3609        Ok(n)
3610    }
3611}
3612
3613async fn upload_tarball_handler(
3614    State(state): State<AppState>,
3615    request: axum::extract::Request,
3616) -> Response {
3617    if !state.server_mode {
3618        return StatusCode::NOT_FOUND.into_response();
3619    }
3620
3621    let upload_id = uuid::Uuid::new_v4().to_string();
3622    let upload_base = upload_base_dir();
3623    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3624    let staging = upload_staging_path(&upload_id);
3625    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3626
3627    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3628        tracing::error!(
3629            event = "upload_io_error",
3630            "failed to create upload base dir: {e}"
3631        );
3632        return (
3633            StatusCode::INTERNAL_SERVER_ERROR,
3634            Json(serde_json::json!({"error": "Upload initialization failed"})),
3635        )
3636            .into_response();
3637    }
3638
3639    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
3640    let compressed_bytes =
3641        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3642            Ok(n) => n,
3643            Err(resp) => return resp,
3644        };
3645
3646    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
3647    if let Err(resp) =
3648        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3649    {
3650        return resp;
3651    }
3652
3653    // ── 3. Find the project root inside the staging dir ───────────────────────
3654    // If the tar contained a single top-level directory (the common case when the
3655    // browser uses `webkitRelativePath`), return that as the scan root so the path
3656    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3657    let scan_root = find_single_top_dir(&staging)
3658        .await
3659        .unwrap_or_else(|| staging.clone());
3660
3661    // Compute original (uncompressed) size of the extracted tree.
3662    let original_bytes = tokio::task::spawn_blocking({
3663        let p = scan_root.clone();
3664        move || dir_size_bytes(&p)
3665    })
3666    .await
3667    .unwrap_or(0);
3668
3669    Json(serde_json::json!({
3670        "tmp_path": scan_root.to_string_lossy(),
3671        "upload_id": upload_id,
3672        "compressed_bytes": compressed_bytes,
3673        "original_bytes": original_bytes,
3674    }))
3675    .into_response()
3676}
3677
3678#[derive(Deserialize)]
3679struct LocateReportForm {
3680    file_path: String,
3681    #[serde(default)]
3682    redirect_url: Option<String>,
3683    #[serde(default)]
3684    expected_run_id: Option<String>,
3685}
3686
3687/// Render a view-reports error page and return it as a `Response`.
3688fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3689    let html = ErrorTemplate {
3690        message: message.into(),
3691        last_report_url: Some("/view-reports".to_string()),
3692        last_report_label: Some("View Reports".to_string()),
3693        run_id: None,
3694        error_code: None,
3695        csp_nonce: csp_nonce.to_owned(),
3696        version: env!("CARGO_PKG_VERSION"),
3697    }
3698    .render()
3699    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3700    Html(html).into_response()
3701}
3702
3703/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3704fn registry_entry_from_run(
3705    run: &AnalysisRun,
3706    json_path: PathBuf,
3707    html_path: PathBuf,
3708) -> RegistryEntry {
3709    let project_label = run.input_roots.first().map_or_else(
3710        || "Unknown Project".to_string(),
3711        |r| sanitize_project_label(r),
3712    );
3713    RegistryEntry {
3714        run_id: run.tool.run_id.clone(),
3715        timestamp_utc: run.tool.timestamp_utc,
3716        project_label,
3717        input_roots: run.input_roots.clone(),
3718        json_path: Some(json_path),
3719        html_path: Some(html_path),
3720        pdf_path: None,
3721        summary: ScanSummarySnapshot::from(&run.summary_totals),
3722        csv_path: None,
3723        xlsx_path: None,
3724        git_branch: None,
3725        git_commit: None,
3726        git_commit_long: None,
3727        git_author: None,
3728        git_tags: None,
3729        git_nearest_tag: None,
3730        git_commit_date: None,
3731    }
3732}
3733
3734/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3735/// immediately without requiring a server restart.
3736pub(crate) async fn register_artifacts_in_registry(
3737    state: &AppState,
3738    label: &str,
3739    run: &AnalysisRun,
3740    artifacts: &RunArtifacts,
3741) {
3742    let Some(json_path) = artifacts.json_path.clone() else {
3743        return;
3744    };
3745    let Some(html_path) = artifacts.html_path.clone() else {
3746        return;
3747    };
3748    let mut entry = registry_entry_from_run(run, json_path, html_path);
3749    entry.project_label = label.to_owned();
3750    let mut reg = state.registry.lock().await;
3751    reg.add_entry(entry);
3752    let _ = reg.save(&state.registry_path);
3753}
3754
3755fn is_html_report_file(p: &Path) -> bool {
3756    p.is_file()
3757        && p.extension()
3758            .and_then(|x| x.to_str())
3759            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3760        && p.file_name()
3761            .and_then(|n| n.to_str())
3762            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3763}
3764
3765fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3766    fs::read_dir(dir)
3767        .ok()?
3768        .flatten()
3769        .map(|e| e.path())
3770        .find(|p| is_html_report_file(p))
3771}
3772
3773fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3774    if let Some(f) = find_html_report_in_dir(dir) {
3775        return Some(f);
3776    }
3777    if let Ok(rd) = fs::read_dir(dir) {
3778        for entry in rd.flatten() {
3779            let sub = entry.path();
3780            if sub.is_dir()
3781                && let Some(f) = find_html_report_in_dir(&sub)
3782            {
3783                return Some(f);
3784            }
3785        }
3786    }
3787    None
3788}
3789
3790/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3791/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3792///
3793/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3794#[allow(clippy::result_large_err)]
3795fn validate_locate_request(
3796    state: &AppState,
3797    file_path: &str,
3798    csp_nonce: &str,
3799) -> Result<(PathBuf, PathBuf), Response> {
3800    let raw = PathBuf::from(file_path);
3801
3802    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3803    let html_path = if raw.is_dir() {
3804        let found = find_html_report_in_tree(&raw);
3805        match found {
3806            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3807            None => {
3808                return Err(locate_report_error(
3809                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3810                     the folder that contains your scan output (result_*.html or report_*.html).",
3811                    csp_nonce,
3812                ));
3813            }
3814        }
3815    } else {
3816        let file_ext = raw
3817            .extension()
3818            .and_then(|e| e.to_str())
3819            .unwrap_or("")
3820            .to_ascii_lowercase();
3821        if file_ext != "html" {
3822            return Err(locate_report_error(
3823                "Please select the scan output folder, or an .html report file directly.",
3824                csp_nonce,
3825            ));
3826        }
3827        match fs::canonicalize(&raw) {
3828            Ok(p) => strip_unc_prefix(p),
3829            Err(_) => {
3830                return Err(locate_report_error(
3831                    "Report file not found or path is invalid.",
3832                    csp_nonce,
3833                ));
3834            }
3835        }
3836    };
3837
3838    if state.server_mode {
3839        let output_root = resolve_output_root(None);
3840        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3841        if !html_path.starts_with(&canonical_root) {
3842            return Err(locate_report_error(
3843                "Report file must be within the configured output directory.",
3844                csp_nonce,
3845            ));
3846        }
3847    }
3848    let parent = match html_path.parent() {
3849        Some(p) => p.to_path_buf(),
3850        None => {
3851            return Err(locate_report_error(
3852                "Report file has no parent directory.",
3853                csp_nonce,
3854            ));
3855        }
3856    };
3857    Ok((html_path, parent))
3858}
3859
3860/// JSON-or-HTML error for `locate_report_handler` error paths.
3861fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3862    if want_json {
3863        (
3864            StatusCode::UNPROCESSABLE_ENTITY,
3865            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3866        )
3867            .into_response()
3868    } else {
3869        locate_report_error(msg, csp_nonce)
3870    }
3871}
3872
3873/// JSON-or-redirect success for locate/relocate handler success paths.
3874fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3875    if want_json {
3876        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3877    } else {
3878        axum::response::Redirect::to(redirect).into_response()
3879    }
3880}
3881
3882/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3883/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3884fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3885    for jpath in candidates {
3886        if let Ok(run) = read_json(jpath)
3887            && (expected.is_empty() || run.tool.run_id == expected)
3888        {
3889            return Some((jpath.clone(), run.tool.run_id));
3890        }
3891    }
3892    None
3893}
3894
3895fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3896    html_path
3897        .parent()
3898        .and_then(|p| p.parent())
3899        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3900}
3901
3902fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3903    let mut hits = collect_result_json_candidates(scan_root);
3904    if hits.is_empty() {
3905        hits = collect_result_json_candidates(parent);
3906    }
3907    hits.sort();
3908    hits
3909}
3910
3911#[allow(clippy::too_many_lines)]
3912async fn locate_report_handler(
3913    State(state): State<AppState>,
3914    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3915    headers: axum::http::HeaderMap,
3916    Form(form): Form<LocateReportForm>,
3917) -> impl IntoResponse {
3918    let want_json = headers
3919        .get(axum::http::header::ACCEPT)
3920        .and_then(|v| v.to_str().ok())
3921        .is_some_and(|v| v.contains("application/json"));
3922
3923    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3924        Ok(v) => v,
3925        Err(resp) => {
3926            if want_json {
3927                return locate_handler_err(
3928                    true,
3929                    "No HTML report file found in the selected folder. \
3930                     Make sure you selected the folder that contains your \
3931                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3932                        .to_string(),
3933                    &csp_nonce,
3934                );
3935            }
3936            return resp;
3937        }
3938    };
3939
3940    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3941    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3942    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3943    let scan_root: &Path = &scan_root_owned;
3944    let json_candidates = gather_json_candidates(scan_root, &parent);
3945
3946    // If the expected_run_id was provided, find a JSON that matches it exactly.
3947    let expected_run_id = form
3948        .expected_run_id
3949        .as_deref()
3950        .unwrap_or("")
3951        .trim()
3952        .to_string();
3953
3954    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3955
3956    // If we have candidates but none matched the expected run_id, surface a clear error.
3957    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3958        let actual = json_candidates
3959            .iter()
3960            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3961            .unwrap_or_else(|| "unknown".to_string());
3962        return locate_handler_err(
3963            want_json,
3964            format!(
3965                "This folder contains a different scan.\n\n\
3966                 Expected run ID : {expected_run_id}\n\
3967                 Found run ID    : {actual}\n\n\
3968                 Please select the folder that contains the correct scan output."
3969            ),
3970            &csp_nonce,
3971        );
3972    }
3973
3974    let safe_redirect = form
3975        .redirect_url
3976        .as_deref()
3977        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3978        .unwrap_or("/view-reports?linked=1")
3979        .to_string();
3980
3981    let mut reg = state.registry.lock().await;
3982
3983    if let Some((json_path, run_id)) = matched_json {
3984        // Match by run_id in the registry (works even after files are moved).
3985        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3986            entry.html_path = Some(html_path);
3987            entry.json_path = Some(json_path);
3988            let _ = reg.save(&state.registry_path);
3989            drop(reg);
3990            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3991            state.artifacts.lock().await.remove(&run_id);
3992            return redirect_or_json_ok(want_json, &safe_redirect);
3993        }
3994        // No existing entry — build one from the JSON.
3995        match read_json(&json_path) {
3996            Ok(run) => {
3997                let entry = registry_entry_from_run(&run, json_path, html_path);
3998                reg.add_entry(entry);
3999                let _ = reg.save(&state.registry_path);
4000                drop(reg);
4001                state.artifacts.lock().await.remove(&run_id);
4002                return redirect_or_json_ok(want_json, &safe_redirect);
4003            }
4004            Err(e) => {
4005                drop(reg);
4006                return locate_handler_err(
4007                    want_json,
4008                    format!(
4009                        "Found the scan folder but could not parse the result JSON.\n\n\
4010                         The file may have been saved by an older version of OxideSLOC. \
4011                         Re-running the analysis will create a fresh, compatible record.\n\n\
4012                         Error: {e}"
4013                    ),
4014                    &csp_nonce,
4015                );
4016            }
4017        }
4018    }
4019
4020    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
4021    if let Some(entry) = reg
4022        .entries
4023        .iter_mut()
4024        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
4025    {
4026        entry.html_path = Some(html_path.clone());
4027        let _ = reg.save(&state.registry_path);
4028        drop(reg);
4029        state.artifacts.lock().await.remove(&expected_run_id);
4030        return redirect_or_json_ok(want_json, &safe_redirect);
4031    }
4032
4033    drop(reg);
4034    let hint = if state.server_mode {
4035        String::new()
4036    } else {
4037        format!(
4038            "\n\nSearched folder : {}\nHTML found      : {}",
4039            scan_root.display(),
4040            html_path.display()
4041        )
4042    };
4043    locate_handler_err(
4044        want_json,
4045        format!(
4046            "Could not link this report.\n\n\
4047             No result_*.json was found in the selected folder. \
4048             Make sure you selected the top-level scan output folder \
4049             (the one that contains html/, json/, pdf/ subfolders).{hint}"
4050        ),
4051        &csp_nonce,
4052    )
4053}
4054
4055/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
4056fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
4057    fs::read_dir(dir)
4058        .ok()?
4059        .flatten()
4060        .map(|e| e.path())
4061        .find(|p| {
4062            p.is_file()
4063                && p.file_stem()
4064                    .and_then(|n| n.to_str())
4065                    .is_some_and(|n| n.starts_with("result"))
4066                && p.extension()
4067                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
4068        })
4069}
4070
4071#[derive(Deserialize)]
4072struct LocateReportsDirForm {
4073    folder_path: String,
4074}
4075
4076#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
4077async fn locate_reports_dir_handler(
4078    State(state): State<AppState>,
4079    Form(form): Form<LocateReportsDirForm>,
4080) -> impl IntoResponse {
4081    if state.server_mode {
4082        return StatusCode::NOT_FOUND.into_response();
4083    }
4084    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
4085        Ok(p) => strip_unc_prefix(p),
4086        Err(_) => {
4087            return axum::response::Redirect::to(
4088                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
4089            )
4090            .into_response();
4091        }
4092    };
4093    if !folder.is_dir() {
4094        return axum::response::Redirect::to(
4095            "/view-reports?error=Selected+path+is+not+a+directory.",
4096        )
4097        .into_response();
4098    }
4099
4100    let candidates = collect_result_json_candidates(&folder);
4101
4102    if candidates.is_empty() {
4103        return axum::response::Redirect::to(
4104            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
4105        )
4106        .into_response();
4107    }
4108
4109    let mut linked_count: usize = 0;
4110    let mut reg = state.registry.lock().await;
4111    for json_path in candidates {
4112        let Some(parent) = json_path.parent().map(PathBuf::from) else {
4113            continue;
4114        };
4115        if is_dir_already_registered(&reg, &parent) {
4116            continue;
4117        }
4118        let Some(entry) = build_registry_entry_from_json(json_path) else {
4119            continue;
4120        };
4121        reg.add_entry(entry);
4122        linked_count += 1;
4123    }
4124    let _ = reg.save(&state.registry_path);
4125    drop(reg);
4126
4127    if linked_count == 0 {
4128        return axum::response::Redirect::to(
4129            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
4130        )
4131        .into_response();
4132    }
4133    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
4134}
4135
4136#[derive(Deserialize)]
4137struct RelocateScanForm {
4138    run_id: String,
4139    folder_path: String,
4140    redirect_url: String,
4141}
4142
4143/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
4144/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
4145fn relocate_folder_err(
4146    want_json: bool,
4147    status: StatusCode,
4148    msg: &str,
4149    run_id: &str,
4150    folder_hint: &str,
4151    redirect_url: &str,
4152    csp_nonce: &str,
4153) -> Response {
4154    if want_json {
4155        (
4156            status,
4157            axum::Json(serde_json::json!({"ok": false, "message": msg})),
4158        )
4159            .into_response()
4160    } else {
4161        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
4162    }
4163}
4164
4165#[allow(clippy::too_many_lines)]
4166async fn relocate_scan_handler(
4167    State(state): State<AppState>,
4168    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4169    headers: axum::http::HeaderMap,
4170    Form(form): Form<RelocateScanForm>,
4171) -> impl IntoResponse {
4172    let want_json = headers
4173        .get(axum::http::header::ACCEPT)
4174        .and_then(|v| v.to_str().ok())
4175        .is_some_and(|v| v.contains("application/json"));
4176    if state.server_mode {
4177        return StatusCode::NOT_FOUND.into_response();
4178    }
4179
4180    let run_id = form.run_id.trim().to_string();
4181    let redirect_url = form.redirect_url.trim().to_string();
4182
4183    let run_exists = {
4184        let reg = state.registry.lock().await;
4185        reg.find_by_run_id(&run_id).is_some()
4186    };
4187    if !run_exists {
4188        if want_json {
4189            return (
4190                StatusCode::NOT_FOUND,
4191                axum::Json(serde_json::json!({
4192                    "ok": false,
4193                    "message": format!("Run ID '{run_id}' not found in registry.")
4194                })),
4195            )
4196                .into_response();
4197        }
4198        let html = ErrorTemplate {
4199            message: format!("Run ID '{run_id}' not found in registry."),
4200            last_report_url: Some("/compare-scans".to_string()),
4201            last_report_label: Some("Compare Scans".to_string()),
4202            run_id: Some(run_id.clone()),
4203            error_code: Some(404),
4204            csp_nonce: csp_nonce.clone(),
4205            version: env!("CARGO_PKG_VERSION"),
4206        }
4207        .render()
4208        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4209        return Html(html).into_response();
4210    }
4211
4212    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
4213        Ok(p) => strip_unc_prefix(p),
4214        Err(_) => {
4215            return relocate_folder_err(
4216                want_json,
4217                StatusCode::UNPROCESSABLE_ENTITY,
4218                "Folder not found or path is invalid.",
4219                &run_id,
4220                form.folder_path.trim(),
4221                &redirect_url,
4222                &csp_nonce,
4223            );
4224        }
4225    };
4226    if !folder.is_dir() {
4227        return relocate_folder_err(
4228            want_json,
4229            StatusCode::UNPROCESSABLE_ENTITY,
4230            "Selected path is not a directory.",
4231            &run_id,
4232            &folder.display().to_string(),
4233            &redirect_url,
4234            &csp_nonce,
4235        );
4236    }
4237
4238    let json_candidates = find_result_files_by_ext(&folder, "json");
4239    if json_candidates.is_empty() {
4240        let msg = format!(
4241            "No result JSON files found in the selected folder.\nSearched: {}",
4242            folder.display()
4243        );
4244        return relocate_folder_err(
4245            want_json,
4246            StatusCode::UNPROCESSABLE_ENTITY,
4247            &msg,
4248            &run_id,
4249            &folder.display().to_string(),
4250            &redirect_url,
4251            &csp_nonce,
4252        );
4253    }
4254
4255    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4256        let msg = format!(
4257            "No matching scan found in the selected folder.\n\
4258             The JSON files present do not contain run ID: {run_id}\n\
4259             Searched: {}",
4260            folder.display()
4261        );
4262        return relocate_folder_err(
4263            want_json,
4264            StatusCode::UNPROCESSABLE_ENTITY,
4265            &msg,
4266            &run_id,
4267            &folder.display().to_string(),
4268            &redirect_url,
4269            &csp_nonce,
4270        );
4271    };
4272
4273    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4274    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4275    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4276
4277    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4278        redirect_url
4279    } else {
4280        "/compare-scans".to_string()
4281    };
4282    redirect_or_json_ok(want_json, &safe_redirect)
4283}
4284
4285fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4286    let mut out = Vec::new();
4287    collect_scan_files_by_ext(folder, ext, &mut out);
4288    if let Ok(rd) = fs::read_dir(folder) {
4289        for entry in rd.flatten() {
4290            let sub = entry.path();
4291            if sub.is_dir() {
4292                collect_scan_files_by_ext(&sub, ext, &mut out);
4293            }
4294        }
4295    }
4296    out
4297}
4298
4299fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4300    let Ok(rd) = fs::read_dir(dir) else { return };
4301    for entry in rd.flatten() {
4302        let p = entry.path();
4303        if p.is_file()
4304            && p.file_stem()
4305                .and_then(|n| n.to_str())
4306                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4307            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4308        {
4309            out.push(p);
4310        }
4311    }
4312}
4313
4314fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4315    candidates
4316        .iter()
4317        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4318        .cloned()
4319}
4320
4321/// Return the best folder hint for the relocate page.
4322/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
4323/// point at the parent — the actual top-level output directory — so the user
4324/// selects the root folder rather than the subfolder.
4325fn output_folder_hint(json_path: &std::path::Path) -> String {
4326    let Some(direct_parent) = json_path.parent() else {
4327        return String::new();
4328    };
4329    let parent_name = direct_parent
4330        .file_name()
4331        .and_then(|n| n.to_str())
4332        .unwrap_or("");
4333    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4334        direct_parent.parent().map_or_else(
4335            || direct_parent.display().to_string(),
4336            |p| p.display().to_string(),
4337        )
4338    } else {
4339        direct_parent.display().to_string()
4340    }
4341}
4342
4343async fn update_run_file_paths(
4344    state: &AppState,
4345    run_id: &str,
4346    json_path: PathBuf,
4347    html_path: Option<PathBuf>,
4348    pdf_path: Option<PathBuf>,
4349) {
4350    {
4351        let mut reg = state.registry.lock().await;
4352        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4353            entry.json_path = Some(json_path.clone());
4354            if let Some(ref hp) = html_path {
4355                entry.html_path = Some(hp.clone());
4356            }
4357            if let Some(ref pp) = pdf_path {
4358                entry.pdf_path = Some(pp.clone());
4359            }
4360        }
4361        let _ = reg.save(&state.registry_path);
4362    }
4363    // Also patch the in-memory artifacts map so the result page picks up the
4364    // new paths without requiring a server restart.
4365    {
4366        let mut map = state.artifacts.lock().await;
4367        if let Some(arts) = map.get_mut(run_id) {
4368            arts.json_path = Some(json_path);
4369            if let Some(hp) = html_path {
4370                arts.html_path = Some(hp);
4371            }
4372            if let Some(pp) = pdf_path {
4373                arts.pdf_path = Some(pp);
4374            }
4375        }
4376    }
4377}
4378
4379fn missing_scan_relocate_response(
4380    message: &str,
4381    run_id: &str,
4382    folder_hint: &str,
4383    redirect_url: &str,
4384    server_mode: bool,
4385    csp_nonce: &str,
4386) -> axum::response::Response {
4387    let html = RelocateScanTemplate {
4388        message: message.to_string(),
4389        run_id: run_id.to_string(),
4390        folder_hint: folder_hint.to_string(),
4391        redirect_url: redirect_url.to_string(),
4392        server_mode,
4393        csp_nonce: csp_nonce.to_owned(),
4394        version: env!("CARGO_PKG_VERSION"),
4395    }
4396    .render()
4397    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4398    (StatusCode::NOT_FOUND, Html(html)).into_response()
4399}
4400
4401// ── Watched-directory helpers ─────────────────────────────────────────────────
4402
4403/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
4404fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4405    fs::read_dir(dir)
4406        .ok()?
4407        .flatten()
4408        .map(|e| e.path())
4409        .find(|p| {
4410            p.is_file()
4411                && p.extension()
4412                    .and_then(|e| e.to_str())
4413                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4414        })
4415}
4416
4417/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
4418/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
4419/// (`<scan_dir>/json/result*.json`).
4420fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4421    let mut out = Vec::new();
4422    if let Some(j) = find_result_json_in_dir(sub) {
4423        out.push(j);
4424    }
4425    let json_sub = sub.join("json");
4426    if json_sub.is_dir()
4427        && let Some(j) = find_result_json_in_dir(&json_sub)
4428    {
4429        out.push(j);
4430    }
4431    out
4432}
4433
4434fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4435    let mut candidates = Vec::new();
4436    if let Some(j) = find_result_json_in_dir(folder) {
4437        candidates.push(j);
4438    }
4439    let Ok(dir_entries) = fs::read_dir(folder) else {
4440        return candidates;
4441    };
4442    for entry in dir_entries.flatten() {
4443        let sub = entry.path();
4444        if sub.is_dir() {
4445            candidates.extend(subdir_result_json_candidates(&sub));
4446        }
4447    }
4448    candidates
4449}
4450
4451fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4452    reg.entries.iter().any(|e| {
4453        let dir_match = e
4454            .json_path
4455            .as_ref()
4456            .and_then(|p| p.parent())
4457            .is_some_and(|p| p == parent)
4458            || e.html_path
4459                .as_ref()
4460                .and_then(|p| p.parent())
4461                .is_some_and(|p| p == parent);
4462        dir_match
4463            && (e.json_path.as_ref().is_some_and(|p| p.exists())
4464                || e.html_path.as_ref().is_some_and(|p| p.exists()))
4465    })
4466}
4467
4468fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4469    let json_dir = json_path.parent()?.to_path_buf();
4470    // If the JSON lives inside a directory named "json", the scan root is its parent
4471    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
4472    let (html_path, pdf_path, csv_path, xlsx_path) =
4473        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4474            let scan_root = json_dir.parent()?;
4475            let html = find_html_report_in_dir(&scan_root.join("html"))
4476                .or_else(|| find_html_report_in_dir(scan_root));
4477            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4478            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4479            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4480            (html, pdf, csv, xlsx)
4481        } else {
4482            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4483                rd.flatten()
4484                    .map(|e| e.path())
4485                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4486            });
4487            (html, None, None, None)
4488        };
4489    let run = read_json(&json_path).ok()?;
4490    let project_label = run.input_roots.first().map_or_else(
4491        || "Unknown Project".to_string(),
4492        |r| sanitize_project_label(r),
4493    );
4494    Some(RegistryEntry {
4495        run_id: run.tool.run_id.clone(),
4496        timestamp_utc: run.tool.timestamp_utc,
4497        project_label,
4498        input_roots: run.input_roots.clone(),
4499        json_path: Some(json_path),
4500        html_path,
4501        pdf_path,
4502        csv_path,
4503        xlsx_path,
4504        summary: ScanSummarySnapshot::from(&run.summary_totals),
4505        git_branch: run.git_branch.clone(),
4506        git_commit: run.git_commit_short.clone(),
4507        git_commit_long: run.git_commit_long.clone(),
4508        git_author: run.git_commit_author.clone(),
4509        git_tags: run.git_tags.clone(),
4510        git_nearest_tag: run.git_nearest_tag.clone(),
4511        git_commit_date: run.git_commit_date,
4512    })
4513}
4514
4515/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
4516/// Returns the number of newly linked entries.
4517fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4518    let mut linked = 0usize;
4519    for json_path in collect_result_json_candidates(folder) {
4520        let Some(parent) = json_path.parent().map(PathBuf::from) else {
4521            continue;
4522        };
4523        if is_dir_already_registered(reg, &parent) {
4524            continue;
4525        }
4526        let Some(entry) = build_registry_entry_from_json(json_path) else {
4527            continue;
4528        };
4529        reg.add_entry(entry);
4530        linked += 1;
4531    }
4532    linked
4533}
4534
4535/// Scan all watched directories (plus the default output root) into `reg`.
4536async fn auto_scan_watched_dirs(state: &AppState) {
4537    let dirs: Vec<PathBuf> = {
4538        let wd = state.watched_dirs.lock().await;
4539        wd.dirs.clone()
4540    };
4541    // Reconcile the registry to the watched-folder model: keep only entries under a
4542    // currently-watched folder or the app's own output directory. This drops leftovers from
4543    // folders that have since been un-watched (which would otherwise linger in the list).
4544    {
4545        let output_root = resolve_output_root(None);
4546        let mut roots: Vec<PathBuf> = dirs.clone();
4547        if let Ok(canon) = fs::canonicalize(&output_root) {
4548            roots.push(strip_unc_prefix(canon));
4549        }
4550        roots.push(output_root);
4551        let mut reg = state.registry.lock().await;
4552        if reg.retain_under_roots(&roots) > 0 {
4553            let _ = reg.save(&state.registry_path);
4554        }
4555    }
4556    if dirs.is_empty() {
4557        return;
4558    }
4559    let mut reg = state.registry.lock().await;
4560    let mut total = 0usize;
4561    for dir in &dirs {
4562        if dir.is_dir() {
4563            total += scan_folder_into_registry(dir, &mut reg);
4564        }
4565    }
4566    if total > 0 {
4567        let _ = reg.save(&state.registry_path);
4568    }
4569}
4570
4571// ── Watched-dir route forms ───────────────────────────────────────────────────
4572
4573#[derive(Deserialize)]
4574struct WatchedDirForm {
4575    folder_path: String,
4576    #[serde(default = "default_redirect")]
4577    redirect_to: String,
4578}
4579
4580fn default_redirect() -> String {
4581    "/view-reports".to_string()
4582}
4583
4584#[derive(Deserialize)]
4585struct WatchedDirRefreshForm {
4586    #[serde(default = "default_redirect")]
4587    redirect_to: String,
4588}
4589
4590// ── Watched-dir helpers ───────────────────────────────────────────────────────
4591
4592/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
4593fn safe_redirect(dest: &str) -> &str {
4594    if dest.starts_with('/') { dest } else { "/" }
4595}
4596
4597// ── Watched-dir handlers ──────────────────────────────────────────────────────
4598
4599async fn add_watched_dir_handler(
4600    State(state): State<AppState>,
4601    Form(form): Form<WatchedDirForm>,
4602) -> impl IntoResponse {
4603    if state.server_mode {
4604        return StatusCode::NOT_FOUND.into_response();
4605    }
4606    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4607        strip_unc_prefix(p)
4608    } else {
4609        let dest = format!(
4610            "{}?error=Folder+not+found+or+path+is+invalid.",
4611            safe_redirect(&form.redirect_to)
4612        );
4613        return axum::response::Redirect::to(&dest).into_response();
4614    };
4615    if !folder.is_dir() {
4616        let dest = format!(
4617            "{}?error=Selected+path+is+not+a+directory.",
4618            safe_redirect(&form.redirect_to)
4619        );
4620        return axum::response::Redirect::to(&dest).into_response();
4621    }
4622
4623    // Persist the watched directory.
4624    {
4625        let mut wd = state.watched_dirs.lock().await;
4626        wd.add(folder.clone());
4627        let _ = wd.save(&state.watched_dirs_path);
4628    }
4629
4630    // Immediately scan the folder and add any new reports.
4631    let linked = {
4632        let mut reg = state.registry.lock().await;
4633        let n = scan_folder_into_registry(&folder, &mut reg);
4634        if n > 0 {
4635            let _ = reg.save(&state.registry_path);
4636        }
4637        n
4638    };
4639
4640    let dest = if linked > 0 {
4641        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4642    } else {
4643        format!(
4644            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4645            safe_redirect(&form.redirect_to)
4646        )
4647    };
4648    axum::response::Redirect::to(&dest).into_response()
4649}
4650
4651async fn remove_watched_dir_handler(
4652    State(state): State<AppState>,
4653    Form(form): Form<WatchedDirForm>,
4654) -> impl IntoResponse {
4655    if state.server_mode {
4656        return StatusCode::NOT_FOUND.into_response();
4657    }
4658    let folder = PathBuf::from(&form.folder_path);
4659    {
4660        let mut wd = state.watched_dirs.lock().await;
4661        wd.remove(&folder);
4662        let _ = wd.save(&state.watched_dirs_path);
4663    }
4664    // Drop any reports that were linked in from this folder so the list reflects the removal.
4665    {
4666        let mut reg = state.registry.lock().await;
4667        if reg.remove_entries_under(&folder) > 0 {
4668            let _ = reg.save(&state.registry_path);
4669        }
4670    }
4671    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4672}
4673
4674async fn refresh_watched_dirs_handler(
4675    State(state): State<AppState>,
4676    Form(form): Form<WatchedDirRefreshForm>,
4677) -> impl IntoResponse {
4678    if state.server_mode {
4679        return StatusCode::NOT_FOUND.into_response();
4680    }
4681    let dirs: Vec<PathBuf> = {
4682        let wd = state.watched_dirs.lock().await;
4683        wd.dirs.clone()
4684    };
4685    let mut total = 0usize;
4686    {
4687        let mut reg = state.registry.lock().await;
4688        reg.prune_stale();
4689        for dir in &dirs {
4690            if dir.is_dir() {
4691                total += scan_folder_into_registry(dir, &mut reg);
4692            }
4693        }
4694        let _ = reg.save(&state.registry_path);
4695    }
4696    let dest = if total > 0 {
4697        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4698    } else {
4699        safe_redirect(&form.redirect_to).to_owned()
4700    };
4701    axum::response::Redirect::to(&dest).into_response()
4702}
4703
4704#[derive(Debug, Deserialize)]
4705struct OpenPathQuery {
4706    path: Option<String>,
4707}
4708
4709fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4710    let mut ancestor = std::path::Path::new(raw);
4711    loop {
4712        match ancestor.parent() {
4713            Some(p) => {
4714                ancestor = p;
4715                if ancestor.is_dir() {
4716                    break;
4717                }
4718            }
4719            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4720        }
4721    }
4722    Ok(ancestor.to_path_buf())
4723}
4724
4725async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4726    match tokio::fs::canonicalize(raw).await {
4727        Ok(canonical) if canonical.is_file() => canonical
4728            .parent()
4729            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4730                Ok(p.to_path_buf())
4731            }),
4732        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4733        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4734        Err(_) => find_existing_ancestor(raw),
4735    }
4736}
4737
4738async fn open_path_handler(
4739    State(state): State<AppState>,
4740    Query(query): Query<OpenPathQuery>,
4741) -> impl IntoResponse {
4742    if state.server_mode {
4743        return Json(serde_json::json!({
4744            "server_mode_disabled": true,
4745            "message": "Opening a path in the file manager is only available in local desktop mode."
4746        }))
4747        .into_response();
4748    }
4749    // Skip the OS file-manager call in headless / CI environments.
4750    if std::env::var("SLOC_HEADLESS").is_ok() {
4751        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4752    }
4753    let raw = match query.path.as_deref() {
4754        Some(p) if !p.is_empty() => p,
4755        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4756    };
4757
4758    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4759    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4760    // so the file explorer still opens somewhere useful.
4761    let target = match resolve_open_target(raw).await {
4762        Ok(p) => p,
4763        Err((code, msg)) => return (code, msg).into_response(),
4764    };
4765
4766    #[cfg(target_os = "windows")]
4767    win_dialog_focus::open_folder_foreground(target);
4768    #[cfg(target_os = "macos")]
4769    let _ = std::process::Command::new("open")
4770        .arg(&target)
4771        .stdout(Stdio::null())
4772        .stderr(Stdio::null())
4773        .spawn();
4774    #[cfg(target_os = "linux")]
4775    {
4776        let folder_name = target
4777            .file_name()
4778            .and_then(|n| n.to_str())
4779            .map(str::to_owned);
4780        let _ = std::process::Command::new("xdg-open")
4781            .arg(&target)
4782            .stdout(Stdio::null())
4783            .stderr(Stdio::null())
4784            .spawn();
4785        // Best-effort: raise the file manager window once it appears.
4786        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4787        // installed; failures are silently discarded.
4788        if let Some(name) = folder_name {
4789            std::thread::spawn(move || {
4790                std::thread::sleep(std::time::Duration::from_millis(800));
4791                let _ = std::process::Command::new("wmctrl")
4792                    .args(["-a", &name])
4793                    .stdout(Stdio::null())
4794                    .stderr(Stdio::null())
4795                    .spawn();
4796            });
4797        }
4798    }
4799
4800    Json(serde_json::json!({"ok": true})).into_response()
4801}
4802
4803async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4804    let (content_type, bytes): (&'static str, &'static [u8]) =
4805        match (folder.as_str(), file.as_str()) {
4806            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4807            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4808            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4809            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4810            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4811            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4812            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4813            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4814            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4815            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4816            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4817            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4818            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4819            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4820            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4821            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4822            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4823            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4824            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4825            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4826            _ => return StatusCode::NOT_FOUND.into_response(),
4827        };
4828    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4829}
4830
4831/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4832/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4833/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4834/// complexity low; the fail-closed semantics are unchanged.
4835fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4836    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4837    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4838    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4839    // sample/upload locations are permitted; everything else is rejected.
4840    let Ok(canonical) = fs::canonicalize(resolved) else {
4841        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4842            return Err(Html(
4843                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4844            ));
4845        }
4846        return Ok(());
4847    };
4848    // Upload temp dirs and built-in sample/fixture paths are always safe.
4849    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4850        return Ok(());
4851    }
4852    let config = &state.base_config;
4853    if config.discovery.allowed_scan_roots.is_empty() {
4854        return Err(Html(
4855            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()
4856        ));
4857    }
4858    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4859        fs::canonicalize(root)
4860            .ok()
4861            .is_some_and(|r| canonical.starts_with(&r))
4862    });
4863    if !allowed {
4864        return Err(Html(
4865            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4866        ));
4867    }
4868    Ok(())
4869}
4870
4871async fn preview_handler(
4872    State(state): State<AppState>,
4873    Query(query): Query<PreviewQuery>,
4874) -> impl IntoResponse {
4875    let raw_path = query
4876        .path
4877        .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4878    let resolved = resolve_input_path(&raw_path);
4879
4880    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4881    // binary whose working directory is not the project root), return a clear message
4882    // instead of an opaque OS error from build_preview_html.
4883    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4884        return Html(
4885            r#"<div class="preview-error">Sample directory not available on this server.
4886            Enter a path to a project directory or upload files using Browse.</div>"#
4887                .to_string(),
4888        );
4889    }
4890
4891    if state.server_mode
4892        && let Err(resp) = authorize_preview_path(&state, &resolved)
4893    {
4894        return resp;
4895    }
4896
4897    let include_patterns = split_patterns(query.include_globs.as_deref());
4898    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4899
4900    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4901        Ok(html) => Html(html),
4902        Err(err) => Html(format!(
4903            r#"<div class="preview-error">Preview failed: {}</div>"#,
4904            escape_html(&err.to_string())
4905        )),
4906    }
4907}
4908
4909#[derive(Debug, Deserialize, Default)]
4910struct SuggestCoverageQuery {
4911    path: Option<String>,
4912}
4913
4914#[derive(Serialize)]
4915struct SuggestCoverageResponse {
4916    found: Option<String>,
4917    tool: Option<&'static str>,
4918    hint: Option<&'static str>,
4919}
4920
4921async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4922    const CANDIDATES: &[&str] = &[
4923        // LCOV — cargo-llvm-cov, gcov, lcov
4924        "coverage/lcov.info",
4925        "lcov.info",
4926        "target/llvm-cov/lcov.info",
4927        "target/coverage/lcov.info",
4928        "target/debug/coverage/lcov.info",
4929        "coverage/coverage.lcov",
4930        "build/coverage/lcov.info",
4931        "reports/lcov.info",
4932        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4933        "coverage.xml",
4934        "coverage/coverage.xml",
4935        "target/site/cobertura/coverage.xml",
4936        "build/reports/coverage/coverage.xml",
4937        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4938        "target/site/jacoco/jacoco.xml",
4939        "build/reports/jacoco/test/jacocoTestReport.xml",
4940        "build/reports/jacoco/jacocoTestReport.xml",
4941        "build/jacoco/jacoco.xml",
4942        // coverage.py native JSON — `coverage json`
4943        "coverage.json",
4944        "coverage/coverage.json",
4945    ];
4946    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4947    let found = CANDIDATES
4948        .iter()
4949        .map(|rel| root.join(rel))
4950        .find(|p| p.is_file())
4951        .map(|p| display_path(&p));
4952
4953    let (tool, hint) = detect_coverage_tool(&root);
4954    Json(SuggestCoverageResponse { found, tool, hint })
4955}
4956
4957/// Inspect the project root for known build/package files and return the most likely coverage
4958/// tool name and the shell command needed to generate a coverage file.
4959fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4960    if root.join("Cargo.toml").is_file() {
4961        return (
4962            Some("cargo-llvm-cov"),
4963            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4964        );
4965    }
4966    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4967        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4968    }
4969    if root.join("pom.xml").is_file() {
4970        return (Some("jacoco"), Some("mvn test jacoco:report"));
4971    }
4972    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4973        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4974    }
4975    (None, None)
4976}
4977
4978/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4979#[allow(clippy::result_large_err)]
4980fn validate_server_scan_path(
4981    config: &sloc_config::AppConfig,
4982    resolved_path: &Path,
4983    csp_nonce: &str,
4984) -> Result<(), Response> {
4985    if config.discovery.allowed_scan_roots.is_empty() {
4986        let template = ErrorTemplate {
4987            message: "Scan path rejected: this server has no scan roots configured, so \
4988                      scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4989                      environment variable (colon-separated absolute paths) — or \
4990                      allowed_scan_roots in the config TOML — then restart. Tip: the \
4991                      Browse / directory-upload flow works without this; uploaded folders \
4992                      are scanned from the server's temp area and bypass this check."
4993                .to_string(),
4994            last_report_url: None,
4995            last_report_label: None,
4996            run_id: None,
4997            error_code: Some(403),
4998            csp_nonce: csp_nonce.to_owned(),
4999            version: env!("CARGO_PKG_VERSION"),
5000        };
5001        return Err((
5002            StatusCode::FORBIDDEN,
5003            Html(
5004                template
5005                    .render()
5006                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5007            ),
5008        )
5009            .into_response());
5010    }
5011    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
5012    // location) we must NOT fall back to the raw, un-normalised path — a textual
5013    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
5014    // allowlist. A non-resolvable scan target is rejected outright.
5015    let Ok(canonical) = fs::canonicalize(resolved_path) else {
5016        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
5017            "Scan path does not resolve to a real location");
5018        let template = ErrorTemplate {
5019            message: "The requested path could not be resolved to a real directory.".to_string(),
5020            last_report_url: None,
5021            last_report_label: None,
5022            run_id: None,
5023            error_code: Some(403),
5024            csp_nonce: csp_nonce.to_owned(),
5025            version: env!("CARGO_PKG_VERSION"),
5026        };
5027        return Err((
5028            StatusCode::FORBIDDEN,
5029            Html(
5030                template
5031                    .render()
5032                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
5033            ),
5034        )
5035            .into_response());
5036    };
5037    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
5038        fs::canonicalize(root)
5039            .ok()
5040            .is_some_and(|r| canonical.starts_with(&r))
5041    });
5042    if !allowed {
5043        tracing::warn!(event = "path_rejected", path = %canonical.display(),
5044            "Scan path not in allowed_scan_roots");
5045        let template = ErrorTemplate {
5046            message: "The requested path is not within an allowed scan directory.".to_string(),
5047            last_report_url: None,
5048            last_report_label: None,
5049            run_id: None,
5050            error_code: Some(403),
5051            csp_nonce: csp_nonce.to_owned(),
5052            version: env!("CARGO_PKG_VERSION"),
5053        };
5054        return Err((
5055            StatusCode::FORBIDDEN,
5056            Html(
5057                template
5058                    .render()
5059                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
5060            ),
5061        )
5062            .into_response());
5063    }
5064    Ok(())
5065}
5066
5067/// Exclude the output directory from scanning so artifacts don't pollute counts.
5068fn apply_output_dir_exclusions(
5069    config: &mut sloc_config::AppConfig,
5070    project_path: &str,
5071    raw_output_dir: &str,
5072) {
5073    let project_root = resolve_input_path(project_path);
5074    let raw_out = raw_output_dir.trim();
5075    let resolved_out = if raw_out.is_empty() {
5076        project_root.join("sloc")
5077    } else if Path::new(raw_out).is_absolute() {
5078        PathBuf::from(raw_out)
5079    } else {
5080        workspace_root().join(raw_out)
5081    };
5082    if let Ok(rel) = resolved_out.strip_prefix(&project_root)
5083        && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
5084    {
5085        let dir = first.to_string();
5086        if !config.discovery.excluded_directories.contains(&dir) {
5087            config.discovery.excluded_directories.push(dir);
5088        }
5089    }
5090    if !config
5091        .discovery
5092        .excluded_directories
5093        .iter()
5094        .any(|d| d == "sloc")
5095    {
5096        config
5097            .discovery
5098            .excluded_directories
5099            .push("sloc".to_string());
5100    }
5101}
5102
5103/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
5104const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
5105    ScanSummarySnapshot {
5106        files_analyzed: run.summary_totals.files_analyzed,
5107        files_skipped: run.summary_totals.files_skipped,
5108        total_physical_lines: run.summary_totals.total_physical_lines,
5109        code_lines: run.summary_totals.code_lines,
5110        comment_lines: run.summary_totals.comment_lines,
5111        blank_lines: run.summary_totals.blank_lines,
5112        functions: run.summary_totals.functions,
5113        classes: run.summary_totals.classes,
5114        variables: run.summary_totals.variables,
5115        imports: run.summary_totals.imports,
5116        test_count: run.summary_totals.test_count,
5117        coverage_lines_found: run.summary_totals.coverage_lines_found,
5118        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
5119        coverage_functions_found: run.summary_totals.coverage_functions_found,
5120        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
5121        coverage_branches_found: run.summary_totals.coverage_branches_found,
5122        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
5123    }
5124}
5125
5126/// Build the `RegistryEntry` for the just-completed scan run.
5127pub(crate) fn build_run_registry_entry(
5128    run: &AnalysisRun,
5129    run_id: &str,
5130    project_label: &str,
5131    artifacts: &RunArtifacts,
5132) -> RegistryEntry {
5133    RegistryEntry {
5134        run_id: run_id.to_owned(),
5135        timestamp_utc: run.tool.timestamp_utc,
5136        project_label: project_label.to_owned(),
5137        input_roots: run.input_roots.clone(),
5138        json_path: artifacts.json_path.clone(),
5139        html_path: artifacts.html_path.clone(),
5140        pdf_path: artifacts.pdf_path.clone(),
5141        csv_path: artifacts.csv_path.clone(),
5142        xlsx_path: artifacts.xlsx_path.clone(),
5143        summary: summary_snapshot_from_run(run),
5144        git_branch: run.git_branch.clone(),
5145        git_commit: run.git_commit_short.clone(),
5146        git_commit_long: run.git_commit_long.clone(),
5147        git_author: run.git_commit_author.clone(),
5148        git_tags: run.git_tags.clone(),
5149        git_nearest_tag: run.git_nearest_tag.clone(),
5150        git_commit_date: run.git_commit_date.clone(),
5151    }
5152}
5153
5154/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
5155fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5156    if let Some(policy) = form.mixed_line_policy {
5157        config.analysis.mixed_line_policy = policy;
5158    }
5159    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
5160    config.analysis.generated_file_detection =
5161        form.generated_file_detection.as_deref() != Some("disabled");
5162    config.analysis.minified_file_detection =
5163        form.minified_file_detection.as_deref() != Some("disabled");
5164    config.analysis.vendor_directory_detection =
5165        form.vendor_directory_detection.as_deref() != Some("disabled");
5166    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
5167    if let Some(binary_behavior) = form.binary_file_behavior {
5168        config.analysis.binary_file_behavior = binary_behavior;
5169    }
5170    apply_report_opts(config, form);
5171    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
5172    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
5173    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
5174    if let Some(policy) = form.continuation_line_policy {
5175        config.analysis.continuation_line_policy = policy;
5176    }
5177    if let Some(policy) = form.blank_in_block_comment_policy {
5178        config.analysis.blank_in_block_comment_policy = policy;
5179    }
5180    config.analysis.count_compiler_directives =
5181        form.count_compiler_directives.as_deref() != Some("disabled");
5182    apply_style_threshold(config, form);
5183    apply_coverage_path(config, form);
5184}
5185
5186fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5187    if let Some(report_title) = form.report_title.as_deref() {
5188        let trimmed = report_title.trim();
5189        if !trimmed.is_empty() {
5190            config.reporting.report_title = trimmed.to_string();
5191        }
5192    }
5193    if let Some(hf) = form.report_header_footer.as_deref() {
5194        let trimmed = hf.trim();
5195        config.reporting.report_header_footer = if trimmed.is_empty() {
5196            None
5197        } else {
5198            Some(trimmed.to_string())
5199        };
5200    }
5201}
5202
5203fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5204    apply_style_col_threshold(config, form);
5205    apply_style_analysis_enabled(config, form);
5206    apply_style_score_threshold(config, form);
5207    apply_style_lang_scope(config, form);
5208    apply_activity_window(config, form);
5209}
5210
5211fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5212    if let Some(threshold_str) = form.style_col_threshold.as_deref()
5213        && let Ok(t) = threshold_str.parse::<u16>()
5214        && (t == 80 || t == 100 || t == 120)
5215    {
5216        config.analysis.style_col_threshold = t;
5217    }
5218}
5219
5220fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5221    if let Some(v) = form.style_analysis_enabled.as_deref() {
5222        config.analysis.style_analysis_enabled = v != "disabled";
5223    }
5224}
5225
5226fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5227    if let Some(v) = form.style_score_threshold.as_deref()
5228        && let Ok(t) = v.parse::<u8>()
5229    {
5230        config.analysis.style_score_threshold = t.min(100);
5231    }
5232}
5233
5234fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5235    if let Some(v) = form.style_lang_scope.as_deref() {
5236        let scope = v.trim();
5237        if scope == "c_family" || scope == "all" {
5238            config.analysis.style_lang_scope = scope.to_string();
5239        }
5240    }
5241}
5242
5243fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5244    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
5245    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
5246    if let Some(w) = form.activity_window.as_deref() {
5247        let w = w.trim();
5248        if !w.is_empty()
5249            && let Ok(days) = w.parse::<u32>()
5250        {
5251            config.analysis.activity_window_days = Some(days);
5252        }
5253    }
5254}
5255
5256fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5257    if let Some(cov) = &form.coverage_file {
5258        let trimmed = cov.trim();
5259        if !trimmed.is_empty() {
5260            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5261        }
5262    }
5263}
5264
5265/// Fire-and-forget: generate the PDF in a background task if one is pending.
5266/// On failure, clears `pdf_path` in the artifacts map so the results page shows
5267/// an error instead of spinning indefinitely.
5268fn spawn_pdf_background(
5269    pending_pdf: PendingPdf,
5270    run_id: String,
5271    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5272) {
5273    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5274        tokio::spawn(async move {
5275            let result = tokio::task::spawn_blocking(move || {
5276                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5277                if cleanup_src {
5278                    let _ = fs::remove_file(&pdf_src);
5279                }
5280                r
5281            })
5282            .await;
5283            let failed = match result {
5284                Ok(Ok(())) => false,
5285                Ok(Err(err)) => {
5286                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5287                    true
5288                }
5289                Err(err) => {
5290                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5291                    true
5292                }
5293            };
5294            if failed {
5295                let mut map = artifacts.lock().await;
5296                if let Some(entry) = map.get_mut(&run_id) {
5297                    entry.pdf_path = None;
5298                }
5299            }
5300        });
5301    }
5302}
5303
5304/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
5305/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
5306/// result page can show an error on the next visit instead of spinning indefinitely.
5307fn spawn_native_pdf_background(
5308    json_path: PathBuf,
5309    pdf_dest: PathBuf,
5310    run_id: String,
5311    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5312) {
5313    tokio::spawn(async move {
5314        let result = tokio::task::spawn_blocking(move || {
5315            let run = sloc_core::read_json(&json_path)?;
5316            write_pdf_from_run(&run, &pdf_dest)
5317        })
5318        .await;
5319        let failed = match result {
5320            Ok(Ok(())) => false,
5321            Ok(Err(err)) => {
5322                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5323                true
5324            }
5325            Err(err) => {
5326                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5327                true
5328            }
5329        };
5330        if failed {
5331            let mut map = artifacts.lock().await;
5332            if let Some(entry) = map.get_mut(&run_id) {
5333                entry.pdf_path = None;
5334            }
5335        }
5336    });
5337}
5338
5339/// Sum the code lines added in this comparison (new + grown files).
5340fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5341    cmp.file_deltas
5342        .iter()
5343        .map(|f| match f.status {
5344            FileChangeStatus::Added => f.current_code,
5345            FileChangeStatus::Modified => f.code_delta.max(0),
5346            _ => 0,
5347        })
5348        .sum()
5349}
5350
5351/// Sum the code lines removed in this comparison (deleted + shrunk files).
5352fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5353    cmp.file_deltas
5354        .iter()
5355        .map(|f| match f.status {
5356            FileChangeStatus::Removed => f.baseline_code,
5357            FileChangeStatus::Modified => (-f.code_delta).max(0),
5358            _ => 0,
5359        })
5360        .sum()
5361}
5362
5363/// Sum the code lines present in both scans without any change (Unchanged files).
5364fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5365    cmp.file_deltas
5366        .iter()
5367        .filter(|f| f.status == FileChangeStatus::Unchanged)
5368        .map(|f| f.current_code)
5369        .sum()
5370}
5371
5372/// Sum the code lines residing in files that were modified between the two scans.
5373fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5374    cmp.file_deltas
5375        .iter()
5376        .filter(|f| f.status == FileChangeStatus::Modified)
5377        .map(|f| f.current_code)
5378        .sum()
5379}
5380
5381/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
5382fn build_submodule_row(
5383    s: &sloc_core::SubmoduleSummary,
5384    run: &AnalysisRun,
5385    run_id: &str,
5386    run_dir: &Path,
5387) -> SubmoduleRow {
5388    let safe = sanitize_project_label(&s.name);
5389    let artifact_key = format!("sub_{safe}");
5390    let pdf_artifact_key = format!("sub_{safe}_pdf");
5391    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5392        let parent_path = run
5393            .input_roots
5394            .first()
5395            .map_or("", std::string::String::as_str);
5396        let sub_run = build_sub_run(run, s, parent_path);
5397        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5398        render_sub_report_html(&sub_run, Some(&pdf_server_url))
5399            .ok()
5400            .and_then(|sub_html| {
5401                let sub_dir = run_dir.join("submodules");
5402                let _ = fs::create_dir_all(&sub_dir);
5403                let html_path = sub_dir.join(format!("{artifact_key}.html"));
5404                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5405                    // Pre-generate the sub-report PDF using the programmatic renderer
5406                    // so "View PDF" never needs to spawn Chrome for submodules.
5407                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5408                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
5409                    Some(format!("/runs/{artifact_key}/{run_id}"))
5410                } else {
5411                    None
5412                }
5413            })
5414    } else {
5415        None
5416    };
5417    SubmoduleRow {
5418        name: s.name.clone(),
5419        relative_path: s.relative_path.clone(),
5420        files_analyzed: s.files_analyzed,
5421        code_lines: s.code_lines,
5422        comment_lines: s.comment_lines,
5423        blank_lines: s.blank_lines,
5424        total_physical_lines: s.total_physical_lines,
5425        html_url,
5426    }
5427}
5428
5429// Immediately returns a wait page and runs the analysis in a background tokio task.
5430// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
5431#[allow(clippy::similar_names)]
5432#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
5433#[allow(clippy::too_many_lines)]
5434async fn analyze_handler(
5435    State(state): State<AppState>,
5436    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5437    Form(form): Form<AnalyzeForm>,
5438) -> impl IntoResponse {
5439    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5440        let template = ErrorTemplate {
5441            message: format!(
5442                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5443             Please wait a moment and try again."
5444            ),
5445            last_report_url: None,
5446            last_report_label: None,
5447            run_id: None,
5448            error_code: Some(503),
5449            csp_nonce: csp_nonce.clone(),
5450            version: env!("CARGO_PKG_VERSION"),
5451        };
5452        return (
5453            StatusCode::SERVICE_UNAVAILABLE,
5454            Html(
5455                template
5456                    .render()
5457                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5458            ),
5459        )
5460            .into_response();
5461    };
5462
5463    let mut config = state.base_config.clone();
5464
5465    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5466    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5467    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5468
5469    if !is_git_mode {
5470        let resolved_path = resolve_input_path(&form.path);
5471        if state.server_mode
5472            && !is_upload_tmp_path(&resolved_path)
5473            && !is_sample_path(&resolved_path)
5474            && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
5475        {
5476            return resp;
5477        }
5478        config.discovery.root_paths = vec![resolved_path];
5479    }
5480
5481    apply_form_to_config(&mut config, &form);
5482    apply_output_dir_exclusions(
5483        &mut config,
5484        &form.path,
5485        form.output_dir.as_deref().unwrap_or(""),
5486    );
5487
5488    // Generate a wait_id now (before spawning) so the client can poll for status.
5489    let wait_id = uuid::Uuid::new_v4().to_string();
5490    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5491
5492    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
5493    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5494    let task_cancel = Arc::clone(&cancel_token);
5495
5496    // Phase tracker: updated by run_analysis_task at key checkpoints.
5497    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5498    let task_phase = Arc::clone(&phase);
5499
5500    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5501    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5502    let task_files_done = Arc::clone(&files_done);
5503    let task_files_total = Arc::clone(&files_total);
5504
5505    // Register Running state before building the task struct so the semaphore permit
5506    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
5507    {
5508        let mut runs = state.async_runs.lock().await;
5509        runs.insert(
5510            wait_id.clone(),
5511            AsyncRunState::Running {
5512                started_at: std::time::Instant::now(),
5513                cancel_token,
5514                phase,
5515                files_done,
5516                files_total,
5517            },
5518        );
5519    }
5520
5521    let task = AnalysisTask {
5522        sem_permit,
5523        state: state.clone(),
5524        wait_id: wait_id.clone(),
5525        config,
5526        cancel: task_cancel,
5527        phase: task_phase,
5528        files_done: task_files_done,
5529        files_total: task_files_total,
5530        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5531        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5532        project_path: form.path.clone(),
5533        // In server mode the client-supplied output_dir is ignored — artifacts are
5534        // always written under the server's configured output root so remote users
5535        // cannot direct writes to arbitrary filesystem paths.
5536        output_dir: if state.server_mode {
5537            None
5538        } else {
5539            form.output_dir.clone()
5540        },
5541        clones_dir: state.git_clones_dir.clone(),
5542        cocomo_mode: form
5543            .cocomo_mode
5544            .clone()
5545            .unwrap_or_else(|| "organic".to_string()),
5546        complexity_alert: form
5547            .complexity_alert
5548            .as_deref()
5549            .and_then(|s| s.parse::<u32>().ok())
5550            .unwrap_or(0),
5551        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5552    };
5553
5554    tokio::spawn(run_analysis_task(task));
5555
5556    let template = ScanWaitTemplate {
5557        version: env!("CARGO_PKG_VERSION"),
5558        wait_id_json,
5559        project_path: form.path.clone(),
5560        csp_nonce,
5561    };
5562    let html = template
5563        .render()
5564        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5565    let mut response = Html(html).into_response();
5566    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
5567        && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
5568    {
5569        response.headers_mut().insert(name, val);
5570    }
5571    response
5572}
5573
5574struct AnalysisTask {
5575    sem_permit: tokio::sync::OwnedSemaphorePermit,
5576    state: AppState,
5577    wait_id: String,
5578    config: AppConfig,
5579    cancel: Arc<std::sync::atomic::AtomicBool>,
5580    phase: Arc<std::sync::Mutex<String>>,
5581    files_done: Arc<std::sync::atomic::AtomicUsize>,
5582    files_total: Arc<std::sync::atomic::AtomicUsize>,
5583    git_repo: Option<String>,
5584    git_ref: Option<String>,
5585    project_path: String,
5586    output_dir: Option<String>,
5587    clones_dir: PathBuf,
5588    cocomo_mode: String,
5589    complexity_alert: u32,
5590    exclude_duplicates: bool,
5591}
5592
5593#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
5594async fn run_analysis_task(task: AnalysisTask) {
5595    let _permit = task.sem_permit;
5596
5597    let cancel_sb = Arc::clone(&task.cancel);
5598    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5599    let clones_dir_sb = task.clones_dir;
5600    // Save the upload staging path before config is moved into spawn_blocking.
5601    let upload_staging_root = task
5602        .config
5603        .discovery
5604        .root_paths
5605        .first()
5606        .filter(|p| is_upload_tmp_path(p))
5607        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5608        .map(PathBuf::from);
5609    let config_sb = task.config;
5610    let progress_sb = sloc_core::ProgressCounters {
5611        files_done: Arc::clone(&task.files_done),
5612        files_total: Arc::clone(&task.files_total),
5613    };
5614    if let Ok(mut p) = task.phase.lock() {
5615        *p = "Scanning files".to_string();
5616    }
5617    let analysis_result = tokio::task::spawn_blocking(move || {
5618        run_analysis_blocking(
5619            config_sb,
5620            git_repo_sb,
5621            git_ref_sb,
5622            clones_dir_sb,
5623            cancel_sb,
5624            Some(progress_sb),
5625        )
5626    })
5627    .await
5628    .map_err(|err| anyhow::anyhow!(err.to_string()))
5629    .and_then(|result| result);
5630
5631    if let Ok(mut p) = task.phase.lock() {
5632        *p = "Writing reports".to_string();
5633    }
5634
5635    // If cancelled while running, discard results and mark as cancelled.
5636    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5637        let mut runs = task.state.async_runs.lock().await;
5638        // Only overwrite if still Running (don't clobber a Complete that snuck in).
5639        if matches!(
5640            runs.get(&task.wait_id),
5641            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5642        ) {
5643            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5644        }
5645        drop(runs);
5646        return;
5647    }
5648
5649    let run = match analysis_result {
5650        Ok(v) => v,
5651        Err(err) => {
5652            // Distinguish user-cancelled from real failure.
5653            if err.to_string().contains("analysis cancelled") {
5654                let mut runs = task.state.async_runs.lock().await;
5655                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5656                drop(runs);
5657                return;
5658            }
5659            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5660            let mut runs = task.state.async_runs.lock().await;
5661            runs.insert(
5662                task.wait_id.clone(),
5663                AsyncRunState::Failed {
5664                    message: "Analysis failed. Check that the path exists and is readable."
5665                        .to_string(),
5666                },
5667            );
5668            drop(runs);
5669            return;
5670        }
5671    };
5672
5673    let run_id = run.tool.run_id.clone();
5674    tracing::info!(event = "scan_complete", run_id = %run_id,
5675        path = %task.project_path, files = run.summary_totals.files_analyzed,
5676        "Analysis finished");
5677
5678    let prev_entry: Option<RegistryEntry> = {
5679        let reg = task.state.registry.lock().await;
5680        reg.entries_for_roots(&run.input_roots)
5681            .into_iter()
5682            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5683            .cloned()
5684    };
5685
5686    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5687        prev.json_path
5688            .as_ref()
5689            .and_then(|p| read_json(p).ok())
5690            .map(|prev_run| compute_delta(&prev_run, &run))
5691    });
5692    let prev_scan_count: usize = {
5693        let reg = task.state.registry.lock().await;
5694        reg.entries_for_roots(&run.input_roots)
5695            .iter()
5696            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5697            .count()
5698    };
5699
5700    // Build the HTML report now that delta is available, so the artifact
5701    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5702    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5703        .as_ref()
5704        .zip(prev_entry.as_ref())
5705        .map(|(cmp, prev)| ReportDeltaContext {
5706            delta_code_added: sum_added_code_lines(cmp),
5707            delta_code_removed: sum_removed_code_lines(cmp),
5708            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5709            delta_files_added: cmp.files_added,
5710            delta_files_removed: cmp.files_removed,
5711            delta_files_modified: cmp.files_modified,
5712            delta_files_unchanged: cmp.files_unchanged,
5713            prev_code_lines: prev.summary.code_lines,
5714            prev_scan_count: prev_scan_count + 1,
5715            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5716            prev_run_id: Some(prev.run_id.clone()),
5717            current_run_id: Some(run_id.clone()),
5718        });
5719    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5720        Ok(h) => h,
5721        Err(err) => {
5722            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5723            let mut runs = task.state.async_runs.lock().await;
5724            runs.insert(
5725                task.wait_id.clone(),
5726                AsyncRunState::Failed {
5727                    message: "Failed to render HTML report.".to_string(),
5728                },
5729            );
5730            drop(runs);
5731            return;
5732        }
5733    };
5734
5735    let output_root = resolve_output_root(task.output_dir.as_deref());
5736    let project_label = derive_project_label(
5737        task.git_repo.as_deref(),
5738        task.git_ref.as_deref(),
5739        &task.project_path,
5740    );
5741    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5742    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5743
5744    let result_context = RunResultContext {
5745        prev_entry: prev_entry.clone(),
5746        prev_scan_count,
5747        project_path: task.project_path.clone(),
5748        cocomo_mode: task.cocomo_mode.clone(),
5749        complexity_alert: task.complexity_alert,
5750        exclude_duplicates: task.exclude_duplicates,
5751    };
5752
5753    let artifact_result = persist_run_artifacts(
5754        &run,
5755        &report_html,
5756        &run_dir,
5757        &run.effective_configuration.reporting.report_title,
5758        &file_stem,
5759        result_context,
5760    );
5761
5762    let (artifacts, pending_pdf) = match artifact_result {
5763        Ok(v) => v,
5764        Err(err) => {
5765            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5766            let mut runs = task.state.async_runs.lock().await;
5767            runs.insert(
5768                task.wait_id.clone(),
5769                AsyncRunState::Failed {
5770                    message: "Failed to save report artifacts. Check available disk space."
5771                        .to_string(),
5772                },
5773            );
5774            drop(runs);
5775            return;
5776        }
5777    };
5778
5779    {
5780        let mut map = task.state.artifacts.lock().await;
5781        map.insert(run_id.clone(), artifacts.clone());
5782    }
5783
5784    {
5785        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5786        let mut reg = task.state.registry.lock().await;
5787        reg.add_entry(entry);
5788        let _ = reg.save(&task.state.registry_path);
5789    }
5790
5791    if let Some(ref cfg_path) = artifacts.scan_config_path {
5792        save_scan_config_json(
5793            cfg_path,
5794            &run,
5795            &task.project_path,
5796            task.output_dir.as_deref(),
5797            &task.cocomo_mode,
5798            task.complexity_alert,
5799            task.exclude_duplicates,
5800        );
5801    }
5802
5803    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5804
5805    prom_runs_total().inc();
5806
5807    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5808    let mut runs = task.state.async_runs.lock().await;
5809    runs.insert(
5810        task.wait_id.clone(),
5811        AsyncRunState::Complete {
5812            run_id: run_id.clone(),
5813        },
5814    );
5815    drop(runs);
5816
5817    // Remove the client-upload staging directory after a successful scan so
5818    // that uploaded project files don't accumulate in the OS temp directory.
5819    if let Some(staging) = upload_staging_root {
5820        let _ = tokio::fs::remove_dir_all(staging).await;
5821    }
5822
5823    let _ = scan_delta;
5824}
5825
5826fn save_scan_config_json(
5827    cfg_path: &std::path::Path,
5828    run: &sloc_core::AnalysisRun,
5829    project_path: &str,
5830    output_dir: Option<&str>,
5831    cocomo_mode: &str,
5832    complexity_alert: u32,
5833    exclude_duplicates: bool,
5834) {
5835    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5836        .ok()
5837        .and_then(|v| v.as_str().map(String::from))
5838        .unwrap_or_else(|| "code_only".to_string());
5839    let behavior_str =
5840        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5841            .ok()
5842            .and_then(|v| v.as_str().map(String::from))
5843            .unwrap_or_else(|| "skip".to_string());
5844    let continuation_policy_str = serde_json::to_value(
5845        run.effective_configuration
5846            .analysis
5847            .continuation_line_policy,
5848    )
5849    .ok()
5850    .and_then(|v| v.as_str().map(String::from))
5851    .unwrap_or_else(default_each_physical_line);
5852    let blank_policy_str = serde_json::to_value(
5853        run.effective_configuration
5854            .analysis
5855            .blank_in_block_comment_policy,
5856    )
5857    .ok()
5858    .and_then(|v| v.as_str().map(String::from))
5859    .unwrap_or_else(default_count_as_comment);
5860    let scan_cfg = ScanConfig {
5861        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5862        path: project_path.to_string(),
5863        include_globs: run
5864            .effective_configuration
5865            .discovery
5866            .include_globs
5867            .join("\n"),
5868        exclude_globs: run
5869            .effective_configuration
5870            .discovery
5871            .exclude_globs
5872            .join("\n"),
5873        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5874        mixed_line_policy: policy_str,
5875        python_docstrings_as_comments: run
5876            .effective_configuration
5877            .analysis
5878            .python_docstrings_as_comments,
5879        generated_file_detection: run
5880            .effective_configuration
5881            .analysis
5882            .generated_file_detection,
5883        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5884        vendor_directory_detection: run
5885            .effective_configuration
5886            .analysis
5887            .vendor_directory_detection,
5888        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5889        binary_file_behavior: behavior_str,
5890        output_dir: output_dir.unwrap_or("").to_string(),
5891        report_title: run.effective_configuration.reporting.report_title.clone(),
5892        continuation_line_policy: continuation_policy_str,
5893        blank_in_block_comment_policy: blank_policy_str,
5894        count_compiler_directives: run
5895            .effective_configuration
5896            .analysis
5897            .count_compiler_directives,
5898        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5899        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5900        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5901        style_lang_scope: run
5902            .effective_configuration
5903            .analysis
5904            .style_lang_scope
5905            .clone(),
5906        coverage_file: run
5907            .effective_configuration
5908            .analysis
5909            .coverage_file
5910            .as_ref()
5911            .map(|p| p.display().to_string())
5912            .unwrap_or_default(),
5913        cocomo_mode: cocomo_mode.to_string(),
5914        complexity_alert,
5915        exclude_duplicates,
5916        activity_window: run
5917            .effective_configuration
5918            .analysis
5919            .activity_window_days
5920            .unwrap_or(0),
5921    };
5922    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5923        let _ = std::fs::write(cfg_path, json);
5924    }
5925}
5926
5927#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5928fn run_analysis_blocking(
5929    mut config: AppConfig,
5930    git_repo: Option<String>,
5931    git_ref: Option<String>,
5932    clones_dir: PathBuf,
5933    cancel: Arc<std::sync::atomic::AtomicBool>,
5934    progress: Option<sloc_core::ProgressCounters>,
5935) -> Result<sloc_core::AnalysisRun> {
5936    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5937        let dest = git_clone_dest(&repo, &clones_dir);
5938        sloc_git::clone_or_fetch(&repo, &dest)?;
5939        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5940        sloc_git::create_worktree(&dest, &refname, &wt)?;
5941        config.discovery.root_paths = vec![wt.clone()];
5942        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5943        let _ = sloc_git::destroy_worktree(&dest, &wt);
5944        let mut run = run?;
5945        if run.git_branch.is_none() {
5946            run.git_branch = Some(refname);
5947        }
5948        return Ok(run);
5949    }
5950    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5951}
5952
5953fn derive_project_label(
5954    git_repo: Option<&str>,
5955    git_ref: Option<&str>,
5956    fallback_path: &str,
5957) -> String {
5958    match (
5959        git_repo.filter(|s| !s.is_empty()),
5960        git_ref.filter(|s| !s.is_empty()),
5961    ) {
5962        (Some(repo), Some(refname)) => {
5963            let repo_name = repo
5964                .trim_end_matches('/')
5965                .trim_end_matches(".git")
5966                .rsplit('/')
5967                .next()
5968                .unwrap_or("repo");
5969            sanitize_project_label(&format!("{repo_name}_{refname}"))
5970        }
5971        _ => sanitize_project_label(fallback_path),
5972    }
5973}
5974
5975fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5976    let commit = commit_short.unwrap_or("").trim();
5977    if commit.is_empty() {
5978        project_label.to_string()
5979    } else {
5980        format!("{project_label}_{commit}")
5981    }
5982}
5983
5984// ── Async scan status + result handlers ──────────────────────────────────────
5985
5986#[derive(Serialize)]
5987#[serde(tag = "state", rename_all = "snake_case")]
5988enum AsyncRunStatusResponse {
5989    Running {
5990        elapsed_secs: u64,
5991        phase: String,
5992        files_done: u64,
5993        files_total: u64,
5994    },
5995    Complete {
5996        run_id: String,
5997    },
5998    Failed {
5999        message: String,
6000    },
6001    Cancelled,
6002}
6003
6004async fn async_run_status_handler(
6005    State(state): State<AppState>,
6006    AxumPath(wait_id): AxumPath<String>,
6007) -> Response {
6008    // wait_id comes from our own UUID generator; reject any structurally malformed value.
6009    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6010        return error::bad_request("invalid wait_id");
6011    }
6012    let run_state = {
6013        let runs = state.async_runs.lock().await;
6014        runs.get(&wait_id).cloned()
6015    };
6016    match run_state {
6017        None => error::not_found("run not found"),
6018        Some(AsyncRunState::Running {
6019            started_at,
6020            phase,
6021            files_done,
6022            files_total,
6023            ..
6024        }) => {
6025            // Treat runs older than 2 h as timed out (analysis should finish well under that).
6026            if started_at.elapsed() > std::time::Duration::from_hours(2) {
6027                let mut runs = state.async_runs.lock().await;
6028                runs.insert(
6029                    wait_id,
6030                    AsyncRunState::Failed {
6031                        message: "Analysis timed out after 2 hours.".to_string(),
6032                    },
6033                );
6034                drop(runs);
6035                return Json(AsyncRunStatusResponse::Failed {
6036                    message: "Analysis timed out after 2 hours.".to_string(),
6037                })
6038                .into_response();
6039            }
6040            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
6041            Json(AsyncRunStatusResponse::Running {
6042                elapsed_secs: started_at.elapsed().as_secs(),
6043                phase: phase_str,
6044                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
6045                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
6046            })
6047            .into_response()
6048        }
6049        Some(AsyncRunState::Complete { run_id }) => {
6050            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
6051        }
6052        Some(AsyncRunState::Failed { message }) => {
6053            Json(AsyncRunStatusResponse::Failed { message }).into_response()
6054        }
6055        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
6056    }
6057}
6058
6059async fn cancel_run_handler(
6060    State(state): State<AppState>,
6061    AxumPath(wait_id): AxumPath<String>,
6062) -> Response {
6063    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
6064        return error::bad_request("invalid wait_id");
6065    }
6066    let mut runs = state.async_runs.lock().await;
6067    let resp = match runs.get(&wait_id) {
6068        Some(AsyncRunState::Running { cancel_token, .. }) => {
6069            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
6070            runs.insert(wait_id, AsyncRunState::Cancelled);
6071            StatusCode::OK.into_response()
6072        }
6073        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
6074        _ => error::not_found("run not found"),
6075    };
6076    drop(runs);
6077    resp
6078}
6079
6080async fn async_run_result_handler(
6081    State(state): State<AppState>,
6082    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6083    AxumPath(run_id): AxumPath<String>,
6084) -> Response {
6085    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
6086        return StatusCode::BAD_REQUEST.into_response();
6087    }
6088
6089    let artifacts = {
6090        let map = state.artifacts.lock().await;
6091        map.get(&run_id).cloned()
6092    };
6093    let artifacts = if let Some(a) = artifacts {
6094        a
6095    } else {
6096        let reg = state.registry.lock().await;
6097        if let Some(entry) = reg.find_by_run_id(&run_id) {
6098            recover_artifacts_from_registry(entry)
6099        } else {
6100            let html = ErrorTemplate {
6101                message: format!(
6102                    "Report not found. Run ID {} is not in the scan history.",
6103                    &run_id[..run_id.len().min(8)]
6104                ),
6105                last_report_url: Some("/view-reports".to_string()),
6106                last_report_label: Some("View Reports".to_string()),
6107                run_id: Some(run_id.clone()),
6108                error_code: Some(404),
6109                csp_nonce: csp_nonce.clone(),
6110                version: env!("CARGO_PKG_VERSION"),
6111            }
6112            .render()
6113            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6114            return (StatusCode::NOT_FOUND, Html(html)).into_response();
6115        }
6116    };
6117
6118    let json_path = if let Some(p) = &artifacts.json_path {
6119        p.clone()
6120    } else {
6121        let html = ErrorTemplate {
6122            message: "JSON result was not saved for this run.".to_string(),
6123            last_report_url: Some("/view-reports".to_string()),
6124            last_report_label: Some("View Reports".to_string()),
6125            run_id: Some(run_id.clone()),
6126            error_code: Some(404),
6127            csp_nonce: csp_nonce.clone(),
6128            version: env!("CARGO_PKG_VERSION"),
6129        }
6130        .render()
6131        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
6132        return (StatusCode::NOT_FOUND, Html(html)).into_response();
6133    };
6134
6135    let Ok(run) = read_json(&json_path) else {
6136        let folder_hint = output_folder_hint(&json_path);
6137        let redirect_url = format!("/runs/result/{run_id}");
6138        return missing_scan_relocate_response(
6139            &format!(
6140                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
6141                 deleted. Browse to the folder containing your scan output to reconnect it.",
6142                json_path.display()
6143            ),
6144            &run_id,
6145            &folder_hint,
6146            &redirect_url,
6147            state.server_mode,
6148            &csp_nonce,
6149        );
6150    };
6151
6152    let confluence_configured = {
6153        let store = state.confluence.lock().await;
6154        store.is_configured()
6155    };
6156
6157    render_result_page(
6158        &run,
6159        &artifacts,
6160        &run_id,
6161        &csp_nonce,
6162        confluence_configured,
6163        state.server_mode,
6164    )
6165}
6166
6167/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
6168fn json_escape(s: &str) -> String {
6169    s.replace('\\', "\\\\").replace('"', "\\\"")
6170}
6171
6172/// Per-language line/symbol totals summed across every language in a run.
6173struct LangTotals {
6174    physical_lines: u64,
6175    code_lines: u64,
6176    comment_lines: u64,
6177    blank_lines: u64,
6178    mixed_lines: u64,
6179    functions: u64,
6180    classes: u64,
6181    variables: u64,
6182    imports: u64,
6183}
6184
6185fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
6186    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
6187        run.totals_by_language.iter().map(f).sum()
6188    };
6189    LangTotals {
6190        physical_lines: s(|r| r.total_physical_lines),
6191        code_lines: s(|r| r.code_lines),
6192        comment_lines: s(|r| r.comment_lines),
6193        blank_lines: s(|r| r.blank_lines),
6194        mixed_lines: s(|r| r.mixed_lines_separate),
6195        functions: s(|r| r.functions),
6196        classes: s(|r| r.classes),
6197        variables: s(|r| r.variables),
6198        imports: s(|r| r.imports),
6199    }
6200}
6201
6202/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
6203struct DeltaFields {
6204    prev_fa_str: String,
6205    prev_fs_str: String,
6206    prev_pl_str: String,
6207    prev_cl_str: String,
6208    prev_cml_str: String,
6209    prev_bl_str: String,
6210    delta_fa_str: String,
6211    delta_fa_class: String,
6212    delta_fs_str: String,
6213    delta_fs_class: String,
6214    delta_pl_str: String,
6215    delta_pl_class: String,
6216    delta_cl_str: String,
6217    delta_cl_class: String,
6218    delta_cml_str: String,
6219    delta_cml_class: String,
6220    delta_bl_str: String,
6221    delta_bl_class: String,
6222    delta_lines_added: Option<i64>,
6223    delta_lines_removed: Option<i64>,
6224    delta_lines_net_str: String,
6225    delta_lines_net_class: String,
6226}
6227
6228// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
6229// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
6230// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
6231// from those field names and obscure the 1:1 mapping.
6232#[allow(
6233    clippy::similar_names,
6234    reason = "locals mirror template-bound struct fields"
6235)]
6236fn compute_delta_fields(
6237    prev_entry: Option<&RegistryEntry>,
6238    totals: &LangTotals,
6239    files_analyzed: u64,
6240    files_skipped: u64,
6241    scan_delta: Option<&sloc_core::ScanComparison>,
6242) -> DeltaFields {
6243    let prev_sum = prev_entry.map(|e| &e.summary);
6244    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6245
6246    let (delta_fa_str, delta_fa_class) =
6247        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6248    let (delta_fs_str, delta_fs_class) =
6249        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6250    let (delta_pl_str, delta_pl_class) = summary_delta(
6251        totals.physical_lines,
6252        prev_sum.map(|s| s.total_physical_lines),
6253    );
6254    let (delta_cl_str, delta_cl_class) =
6255        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6256    let (delta_cml_str, delta_cml_class) =
6257        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6258    let (delta_bl_str, delta_bl_class) =
6259        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6260
6261    let delta_lines_added = scan_delta.map(sum_added_code_lines);
6262    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6263    let (delta_lines_net_str, delta_lines_net_class) =
6264        match (delta_lines_added, delta_lines_removed) {
6265            (Some(a), Some(r)) => {
6266                let net = a - r;
6267                (fmt_delta(net), delta_class(net).to_string())
6268            }
6269            _ => ("\u{2014}".to_string(), "na".to_string()),
6270        };
6271
6272    DeltaFields {
6273        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6274        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6275        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6276        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6277        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6278        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6279        delta_fa_str,
6280        delta_fa_class: delta_fa_class.to_string(),
6281        delta_fs_str,
6282        delta_fs_class: delta_fs_class.to_string(),
6283        delta_pl_str,
6284        delta_pl_class: delta_pl_class.to_string(),
6285        delta_cl_str,
6286        delta_cl_class: delta_cl_class.to_string(),
6287        delta_cml_str,
6288        delta_cml_class: delta_cml_class.to_string(),
6289        delta_bl_str,
6290        delta_bl_class: delta_bl_class.to_string(),
6291        delta_lines_added,
6292        delta_lines_removed,
6293        delta_lines_net_str,
6294        delta_lines_net_class,
6295    }
6296}
6297
6298/// Count of unchanged code lines in a scan comparison.
6299fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6300    scan_delta
6301        .file_deltas
6302        .iter()
6303        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6304        .map(|f| {
6305            #[allow(clippy::cast_sign_loss)]
6306            let n = f.current_code as u64;
6307            n
6308        })
6309        .sum()
6310}
6311
6312fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6313    run.git_remote_url
6314        .as_deref()
6315        .zip(run.git_commit_long.as_deref())
6316        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6317}
6318
6319fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6320    run.git_remote_url
6321        .as_deref()
6322        .zip(run.git_branch.as_deref())
6323        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6324}
6325
6326fn scan_performed_by(run: &AnalysisRun) -> String {
6327    run.environment.ci_name.clone().unwrap_or_else(|| {
6328        format!(
6329            "{} / {}",
6330            run.environment.initiator_username, run.environment.initiator_hostname
6331        )
6332    })
6333}
6334
6335/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
6336fn build_lang_chart_json(run: &AnalysisRun) -> String {
6337    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6338    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6339    let entries: Vec<String> = langs
6340        .into_iter()
6341        .take(12)
6342        .map(|l| {
6343            let name = json_escape(l.language.display_name());
6344            format!(
6345                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6346                name,
6347                l.code_lines,
6348                l.comment_lines,
6349                l.blank_lines,
6350                l.total_physical_lines,
6351                l.functions,
6352                l.classes,
6353                l.variables,
6354                l.imports,
6355                l.files,
6356            )
6357        })
6358        .collect();
6359    format!("[{}]", entries.join(","))
6360}
6361
6362/// Per-language files-vs-lines points as a JSON array for the scatter chart.
6363fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6364    let entries: Vec<String> = run
6365        .totals_by_language
6366        .iter()
6367        .map(|l| {
6368            let name = json_escape(l.language.display_name());
6369            format!(
6370                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6371                name, l.files, l.code_lines, l.total_physical_lines,
6372            )
6373        })
6374        .collect();
6375    format!("[{}]", entries.join(","))
6376}
6377
6378/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
6379fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6380    let entries: Vec<String> = run
6381        .totals_by_language
6382        .iter()
6383        .filter(|l| {
6384            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6385        })
6386        .map(|l| {
6387            let name = json_escape(l.language.display_name());
6388            format!(
6389                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6390                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6391            )
6392        })
6393        .collect();
6394    format!("[{}]", entries.join(","))
6395}
6396
6397/// Per-submodule line counts as a JSON array for the submodule chart.
6398fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6399    let entries: Vec<String> = run
6400        .submodule_summaries
6401        .iter()
6402        .map(|s| {
6403            let name = json_escape(&s.name);
6404            format!(
6405                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6406                name,
6407                s.code_lines,
6408                s.comment_lines,
6409                s.blank_lines,
6410                s.total_physical_lines,
6411                s.files_analyzed,
6412            )
6413        })
6414        .collect();
6415    format!("[{}]", entries.join(","))
6416}
6417
6418/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
6419#[allow(clippy::cast_precision_loss)]
6420fn cov_pct_str(hit: u64, found: u64) -> String {
6421    if found > 0 {
6422        format!("{:.1}", hit as f64 / found as f64 * 100.0)
6423    } else {
6424        String::new()
6425    }
6426}
6427
6428/// `hit / found` summary string, or empty when nothing was found.
6429fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6430    if found > 0 {
6431        format!("{hit} / {found}")
6432    } else {
6433        String::new()
6434    }
6435}
6436
6437const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6438    use sloc_core::CocomoMode;
6439    match mode {
6440        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6441        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6442        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6443    }
6444}
6445
6446const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6447    use sloc_core::CocomoMode;
6448    match mode {
6449        CocomoMode::Organic => "Organic",
6450        CocomoMode::SemiDetached => "Semi-detached",
6451        CocomoMode::Embedded => "Embedded",
6452    }
6453}
6454
6455const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6456    use sloc_core::CocomoMode;
6457    match mode {
6458        CocomoMode::Organic => {
6459            "Organic: A small team working on a well-understood project in a familiar \
6460             environment with minimal external constraints. Suited for internal tools, \
6461             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6462        }
6463        CocomoMode::SemiDetached => {
6464            "Semi-detached: A mixed team with varying experience tackling a project with \
6465             moderate novelty and some rigid constraints. Typical for compilers, transaction \
6466             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6467        }
6468        CocomoMode::Embedded => {
6469            "Embedded: Tight hardware, software, or operational constraints requiring \
6470             significant innovation and deep integration work. Typical for real-time control \
6471             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6472        }
6473    }
6474}
6475
6476/// COCOMO display strings recomputed for the scan-wizard-selected mode.
6477struct CocomoFields {
6478    has_cocomo: bool,
6479    effort_str: String,
6480    duration_str: String,
6481    staff_str: String,
6482    ksloc_str: String,
6483    mode_label: String,
6484    mode_tooltip: String,
6485}
6486
6487#[allow(clippy::cast_precision_loss)]
6488fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6489    use sloc_core::CocomoMode;
6490    let mode = match mode_str {
6491        "semi_detached" => CocomoMode::SemiDetached,
6492        "embedded" => CocomoMode::Embedded,
6493        _ => CocomoMode::Organic,
6494    };
6495    let (a, b, c, d) = cocomo_coefficients(mode);
6496    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6497    let effort = a * ksloc.powf(b);
6498    let duration = c * effort.powf(d);
6499    let staff = if duration > 0.0 {
6500        effort / duration
6501    } else {
6502        0.0
6503    };
6504    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6505    let mode_label = cocomo_mode_label(mode).to_string();
6506    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6507    if run.summary_totals.code_lines > 0 {
6508        CocomoFields {
6509            has_cocomo: true,
6510            effort_str: round2(effort),
6511            duration_str: round2(duration),
6512            staff_str: round2(staff),
6513            ksloc_str: round2(ksloc),
6514            mode_label,
6515            mode_tooltip,
6516        }
6517    } else {
6518        CocomoFields {
6519            has_cocomo: false,
6520            effort_str: String::new(),
6521            duration_str: String::new(),
6522            staff_str: String::new(),
6523            ksloc_str: String::new(),
6524            mode_label,
6525            mode_tooltip,
6526        }
6527    }
6528}
6529
6530#[allow(clippy::too_many_lines)]
6531#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
6532#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
6533fn render_result_page(
6534    run: &AnalysisRun,
6535    artifacts: &RunArtifacts,
6536    run_id: &str,
6537    csp_nonce: &str,
6538    confluence_configured: bool,
6539    server_mode: bool,
6540) -> Response {
6541    let ctx = &artifacts.result_context;
6542    let prev_entry = &ctx.prev_entry;
6543    let prev_scan_count = ctx.prev_scan_count;
6544    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
6545    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
6546    // field is never blank.
6547    let project_path_owned = if ctx.project_path.is_empty() {
6548        run.input_roots.join(", ")
6549    } else {
6550        ctx.project_path.clone()
6551    };
6552    let project_path = &project_path_owned;
6553
6554    let scan_delta = prev_entry.as_ref().and_then(|prev| {
6555        prev.json_path
6556            .as_ref()
6557            .and_then(|p| read_json(p).ok())
6558            .map(|prev_run| compute_delta(&prev_run, run))
6559    });
6560
6561    let files_analyzed = run.per_file_records.len() as u64;
6562    let files_skipped = run.skipped_file_records.len() as u64;
6563    let totals = sum_lang_totals(run);
6564
6565    let DeltaFields {
6566        prev_fa_str,
6567        prev_fs_str,
6568        prev_pl_str,
6569        prev_cl_str,
6570        prev_cml_str,
6571        prev_bl_str,
6572        delta_fa_str,
6573        delta_fa_class,
6574        delta_fs_str,
6575        delta_fs_class,
6576        delta_pl_str,
6577        delta_pl_class,
6578        delta_cl_str,
6579        delta_cl_class,
6580        delta_cml_str,
6581        delta_cml_class,
6582        delta_bl_str,
6583        delta_bl_class,
6584        delta_lines_added,
6585        delta_lines_removed,
6586        delta_lines_net_str,
6587        delta_lines_net_class,
6588    } = compute_delta_fields(
6589        prev_entry.as_ref(),
6590        &totals,
6591        files_analyzed,
6592        files_skipped,
6593        scan_delta.as_ref(),
6594    );
6595
6596    let run_dir = artifacts.output_dir.clone();
6597    let git_branch = run.git_branch.clone();
6598    let git_commit = run.git_commit_short.clone();
6599    let git_commit_long = run.git_commit_long.clone();
6600    let git_author = run.git_commit_author.clone();
6601    let git_commit_url = git_commit_url_for(run);
6602    let git_branch_url = git_branch_url_for(run);
6603    let scan_performed_by = scan_performed_by(run);
6604    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6605    let os_display = format!(
6606        "{} / {}",
6607        run.environment.operating_system, run.environment.architecture
6608    );
6609    let test_count = run.summary_totals.test_count;
6610
6611    // ── New metrics ──────────────────────────────────────────────────────────
6612    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6613    let lsloc = run.summary_totals.lsloc;
6614    let uloc = run.uloc;
6615    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6616    let duplicate_group_count = run.duplicate_groups.len();
6617
6618    // Re-compute COCOMO with the mode selected in the scan wizard.
6619    let ctx = &artifacts.result_context;
6620    let CocomoFields {
6621        has_cocomo,
6622        effort_str: cocomo_effort_str,
6623        duration_str: cocomo_duration_str,
6624        staff_str: cocomo_staff_str,
6625        ksloc_str: cocomo_ksloc_str,
6626        mode_label: cocomo_mode_label,
6627        mode_tooltip: cocomo_mode_tooltip,
6628    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6629    let complexity_alert = ctx.complexity_alert;
6630
6631    let template = ResultTemplate {
6632        version: env!("CARGO_PKG_VERSION"),
6633        report_title: run.effective_configuration.reporting.report_title.clone(),
6634        project_path: project_path.clone(),
6635        output_dir: display_path(&artifacts.output_dir),
6636        run_id: run_id.to_owned(),
6637        run_id_short: run_id
6638            .split('-')
6639            .next_back()
6640            .unwrap_or(run_id)
6641            .chars()
6642            .take(7)
6643            .collect(),
6644        files_analyzed,
6645        files_skipped,
6646        physical_lines: totals.physical_lines,
6647        code_lines: totals.code_lines,
6648        comment_lines: totals.comment_lines,
6649        blank_lines: totals.blank_lines,
6650        mixed_lines: totals.mixed_lines,
6651        functions: totals.functions,
6652        classes: totals.classes,
6653        variables: totals.variables,
6654        imports: totals.imports,
6655        html_url: artifacts
6656            .html_path
6657            .as_ref()
6658            .map(|_| format!("/runs/html/{run_id}")),
6659        pdf_url: artifacts
6660            .pdf_path
6661            .as_ref()
6662            .map(|_| format!("/runs/pdf/{run_id}")),
6663        json_url: artifacts
6664            .json_path
6665            .as_ref()
6666            .map(|_| format!("/runs/json/{run_id}")),
6667        html_download_url: artifacts
6668            .html_path
6669            .as_ref()
6670            .map(|_| format!("/runs/html/{run_id}?download=1")),
6671        pdf_download_url: artifacts
6672            .pdf_path
6673            .as_ref()
6674            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6675        json_download_url: artifacts
6676            .json_path
6677            .as_ref()
6678            .map(|_| format!("/runs/json/{run_id}?download=1")),
6679        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6680        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6681        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6682        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6683        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6684        prev_fa_str,
6685        prev_fs_str,
6686        prev_pl_str,
6687        prev_cl_str,
6688        prev_cml_str,
6689        prev_bl_str,
6690        delta_fa_str,
6691        delta_fa_class,
6692        delta_fs_str,
6693        delta_fs_class,
6694        delta_pl_str,
6695        delta_pl_class,
6696        delta_cl_str,
6697        delta_cl_class,
6698        delta_cml_str,
6699        delta_cml_class,
6700        delta_bl_str,
6701        delta_bl_class,
6702        delta_lines_added,
6703        delta_lines_removed,
6704        delta_lines_net_str,
6705        delta_lines_net_class,
6706        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6707        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6708        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6709        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6710        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6711        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6712        git_branch,
6713        git_branch_url,
6714        git_commit,
6715        git_commit_long,
6716        git_author,
6717        git_commit_url,
6718        scan_performed_by,
6719        scan_time_display,
6720        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6721        os_display,
6722        test_count,
6723        test_assertion_count: run.summary_totals.test_assertion_count,
6724        current_scan_number: prev_scan_count + 1,
6725        prev_scan_count,
6726        submodule_rows: run
6727            .submodule_summaries
6728            .iter()
6729            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6730            .collect(),
6731        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6732        scan_config_url: format!("/runs/scan-config/{run_id}"),
6733        lang_chart_json: build_lang_chart_json(run),
6734        scatter_chart_json: build_scatter_chart_json(run),
6735        semantic_chart_json: build_semantic_chart_json(run),
6736        submodule_chart_json: build_submodule_chart_json(run),
6737        has_submodule_data: !run.submodule_summaries.is_empty(),
6738        has_semantic_data: run
6739            .totals_by_language
6740            .iter()
6741            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6742        csp_nonce: csp_nonce.to_owned(),
6743        confluence_configured,
6744        server_mode,
6745        report_header_footer: run
6746            .effective_configuration
6747            .reporting
6748            .report_header_footer
6749            .clone(),
6750        is_offline: false,
6751        cyclomatic_complexity,
6752        lsloc,
6753        uloc,
6754        dryness_pct_str,
6755        duplicate_group_count,
6756        has_cocomo,
6757        cocomo_effort_str,
6758        cocomo_duration_str,
6759        cocomo_staff_str,
6760        cocomo_ksloc_str,
6761        cocomo_mode_label,
6762        cocomo_mode_tooltip,
6763        complexity_alert,
6764        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6765        cov_line_pct: cov_pct_str(
6766            run.summary_totals.coverage_lines_hit,
6767            run.summary_totals.coverage_lines_found,
6768        ),
6769        cov_fn_pct: cov_pct_str(
6770            run.summary_totals.coverage_functions_hit,
6771            run.summary_totals.coverage_functions_found,
6772        ),
6773        cov_branch_pct: cov_pct_str(
6774            run.summary_totals.coverage_branches_hit,
6775            run.summary_totals.coverage_branches_found,
6776        ),
6777        cov_lines_summary: cov_lines_summary_str(
6778            run.summary_totals.coverage_lines_hit,
6779            run.summary_totals.coverage_lines_found,
6780        ),
6781    };
6782
6783    Html(
6784        template
6785            .render()
6786            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6787    )
6788    .into_response()
6789}
6790
6791fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6792    let slug: String = report_title
6793        .chars()
6794        .map(|c| {
6795            if c.is_alphanumeric() || c == '-' {
6796                c.to_ascii_lowercase()
6797            } else {
6798                '_'
6799            }
6800        })
6801        .collect::<String>()
6802        .split('_')
6803        .filter(|s| !s.is_empty())
6804        .collect::<Vec<_>>()
6805        .join("_");
6806
6807    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6808
6809    if slug.is_empty() {
6810        format!("report_{short_id}.pdf")
6811    } else {
6812        format!("{slug}_{short_id}.pdf")
6813    }
6814}
6815
6816#[derive(Serialize)]
6817struct PdfStatusResponse {
6818    ready: bool,
6819}
6820
6821/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6822/// Clients poll this to update the button state without page reloads.
6823async fn pdf_status_handler(
6824    State(state): State<AppState>,
6825    AxumPath(run_id): AxumPath<String>,
6826) -> Response {
6827    let pdf_path = {
6828        let registry = state.artifacts.lock().await;
6829        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6830    };
6831    let pdf_path = if pdf_path.is_some() {
6832        pdf_path
6833    } else {
6834        let reg = state.registry.lock().await;
6835        reg.find_by_run_id(&run_id)
6836            .map(recover_artifacts_from_registry)
6837            .and_then(|a| a.pdf_path)
6838    };
6839    let ready = pdf_path.is_some_and(|p| p.exists());
6840    Json(PdfStatusResponse { ready }).into_response()
6841}
6842
6843/// GET /`api/runs/:run_id/bundle`
6844///
6845/// Streams a gzip-compressed tar archive containing every artifact in the run's
6846/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6847/// is built in memory so it never touches a temp file.
6848async fn download_bundle_handler(
6849    State(state): State<AppState>,
6850    AxumPath(run_id): AxumPath<String>,
6851) -> Response {
6852    // Resolve output directory from in-memory cache or persisted registry.
6853    let output_dir = {
6854        let cache = state.artifacts.lock().await;
6855        cache.get(&run_id).map(|a| a.output_dir.clone())
6856    };
6857    let output_dir = if let Some(d) = output_dir {
6858        d
6859    } else {
6860        let reg = state.registry.lock().await;
6861        match reg.find_by_run_id(&run_id) {
6862            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6863            None => {
6864                return (
6865                    StatusCode::NOT_FOUND,
6866                    Json(serde_json::json!({"error": "Run not found"})),
6867                )
6868                    .into_response();
6869            }
6870        }
6871    };
6872
6873    if !output_dir.exists() {
6874        return (
6875            StatusCode::NOT_FOUND,
6876            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6877        )
6878            .into_response();
6879    }
6880
6881    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6882    let run_id_clone = run_id.clone();
6883    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6884        use flate2::{Compression, write::GzEncoder};
6885        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6886        {
6887            let mut tar = tar::Builder::new(&mut enc);
6888            tar.follow_symlinks(false);
6889            // Append every regular file in the output directory, skipping
6890            // sub-directories (the output dir is always flat).
6891            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6892                for entry in entries.filter_map(Result::ok) {
6893                    let p = entry.path();
6894                    if p.is_file() {
6895                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6896                        let archive_path = format!("{run_id_clone}/{name}");
6897                        tar.append_path_with_name(&p, &archive_path)?;
6898                    }
6899                }
6900            }
6901            tar.finish()?;
6902        }
6903        Ok(enc.finish()?)
6904    })
6905    .await;
6906
6907    match archive_result {
6908        Ok(Ok(bytes)) => {
6909            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6910            axum::response::Response::builder()
6911                .status(StatusCode::OK)
6912                .header("Content-Type", "application/gzip")
6913                .header(
6914                    "Content-Disposition",
6915                    format!("attachment; filename=\"{filename}\""),
6916                )
6917                .header("Content-Length", bytes.len().to_string())
6918                .body(axum::body::Body::from(bytes))
6919                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6920        }
6921        Ok(Err(e)) => (
6922            StatusCode::INTERNAL_SERVER_ERROR,
6923            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6924        )
6925            .into_response(),
6926        Err(e) => (
6927            StatusCode::INTERNAL_SERVER_ERROR,
6928            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6929        )
6930            .into_response(),
6931    }
6932}
6933
6934/// DELETE /`api/runs/:run_id`
6935///
6936/// Removes all on-disk artifacts for the run and purges the run from the
6937/// in-memory cache and the persisted registry. Returns 204 on success.
6938async fn delete_run_handler(
6939    State(state): State<AppState>,
6940    AxumPath(run_id): AxumPath<String>,
6941) -> Response {
6942    // Resolve output directory.
6943    let output_dir = {
6944        let mut cache = state.artifacts.lock().await;
6945        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6946        cache.remove(&run_id);
6947        dir
6948    };
6949    let output_dir = if let Some(d) = output_dir {
6950        d
6951    } else {
6952        let reg = state.registry.lock().await;
6953        reg.find_by_run_id(&run_id)
6954            .map(|e| recover_artifacts_from_registry(e).output_dir)
6955            .unwrap_or_default()
6956    };
6957
6958    // Remove from persisted registry.
6959    {
6960        let mut reg = state.registry.lock().await;
6961        reg.entries.retain(|e| e.run_id != run_id);
6962        let _ = reg.save(&state.registry_path);
6963    }
6964
6965    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6966    // a prior delete may have already removed the directory.
6967    if output_dir.exists() {
6968        match tokio::fs::remove_dir_all(&output_dir).await {
6969            Ok(()) => {}
6970            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6971            Err(e) => {
6972                return (
6973                    StatusCode::INTERNAL_SERVER_ERROR,
6974                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6975                )
6976                    .into_response();
6977            }
6978        }
6979    }
6980
6981    StatusCode::NO_CONTENT.into_response()
6982}
6983
6984/// POST /api/runs/cleanup
6985///
6986/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6987/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6988async fn cleanup_runs_handler(
6989    State(state): State<AppState>,
6990    Json(body): Json<serde_json::Value>,
6991) -> Response {
6992    let days = body
6993        .get("older_than_days")
6994        .and_then(serde_json::Value::as_u64)
6995        .unwrap_or(30)
6996        .max(1);
6997
6998    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6999
7000    // Collect expired entries from the registry.
7001    let expired: Vec<(String, PathBuf)> = {
7002        let reg = state.registry.lock().await;
7003        reg.entries
7004            .iter()
7005            .filter(|e| e.timestamp_utc < cutoff)
7006            .map(|e| {
7007                let arts = recover_artifacts_from_registry(e);
7008                (e.run_id.clone(), arts.output_dir)
7009            })
7010            .collect()
7011    };
7012
7013    let mut deleted = 0usize;
7014    for (run_id, output_dir) in &expired {
7015        // Remove from in-memory cache.
7016        state.artifacts.lock().await.remove(run_id);
7017        // Delete on-disk artifacts (non-fatal if already gone).
7018        if output_dir.exists()
7019            && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
7020        {
7021            eprintln!(
7022                "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
7023                output_dir.display()
7024            );
7025            continue;
7026        }
7027        deleted += 1;
7028    }
7029
7030    // Purge expired run IDs from the registry in one pass.
7031    let expired_ids: std::collections::HashSet<&str> =
7032        expired.iter().map(|(id, _)| id.as_str()).collect();
7033    {
7034        let mut reg = state.registry.lock().await;
7035        reg.entries
7036            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
7037        let _ = reg.save(&state.registry_path);
7038    }
7039
7040    Json(serde_json::json!({ "deleted": deleted })).into_response()
7041}
7042
7043/// Spawns the background auto-cleanup task. Returns a handle so the caller can
7044/// abort it when the policy is updated or disabled.
7045fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
7046    tokio::spawn(async move {
7047        loop {
7048            let interval_secs = {
7049                let store = state.cleanup_policy.lock().await;
7050                match &store.policy {
7051                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
7052                    _ => break,
7053                }
7054            };
7055            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
7056            let n = run_auto_cleanup(&state).await;
7057            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
7058        }
7059    })
7060}
7061
7062fn collect_runs_to_delete(
7063    reg: &ScanRegistry,
7064    max_age_days: Option<u32>,
7065    max_run_count: Option<u32>,
7066) -> std::collections::HashSet<String> {
7067    let mut to_delete = std::collections::HashSet::new();
7068    if let Some(days) = max_age_days {
7069        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
7070        for e in &reg.entries {
7071            if e.timestamp_utc < cutoff {
7072                to_delete.insert(e.run_id.clone());
7073            }
7074        }
7075    }
7076    if let Some(max_count) = max_run_count {
7077        // entries are sorted newest-first; skip the ones we keep
7078        for e in reg.entries.iter().skip(max_count as usize) {
7079            to_delete.insert(e.run_id.clone());
7080        }
7081    }
7082    to_delete
7083}
7084
7085async fn delete_run_artifacts(state: &AppState, run_id: &str) {
7086    let output_dir = {
7087        let mut cache = state.artifacts.lock().await;
7088        let d = cache.get(run_id).map(|a| a.output_dir.clone());
7089        cache.remove(run_id);
7090        d
7091    };
7092    let output_dir = if let Some(d) = output_dir {
7093        d
7094    } else {
7095        let reg = state.registry.lock().await;
7096        reg.find_by_run_id(run_id)
7097            .map(|e| recover_artifacts_from_registry(e).output_dir)
7098            .unwrap_or_default()
7099    };
7100    if output_dir.exists() {
7101        let _ = tokio::fs::remove_dir_all(&output_dir).await;
7102    }
7103}
7104
7105/// Core cleanup logic shared by the background task and the "Run Now" handler.
7106/// Applies both the age limit and the count limit, then updates `last_run_at`.
7107/// Returns the number of runs deleted.
7108async fn run_auto_cleanup(state: &AppState) -> u32 {
7109    let (max_age_days, max_run_count) = {
7110        let store = state.cleanup_policy.lock().await;
7111        match &store.policy {
7112            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
7113            _ => return 0,
7114        }
7115    };
7116
7117    let to_delete = {
7118        let reg = state.registry.lock().await;
7119        collect_runs_to_delete(&reg, max_age_days, max_run_count)
7120    };
7121
7122    for run_id in &to_delete {
7123        delete_run_artifacts(state, run_id).await;
7124    }
7125
7126    // Purge from registry.
7127    if !to_delete.is_empty() {
7128        let mut reg = state.registry.lock().await;
7129        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
7130        let _ = reg.save(&state.registry_path);
7131    }
7132
7133    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
7134    {
7135        let mut store = state.cleanup_policy.lock().await;
7136        store.last_run_at = Some(chrono::Utc::now());
7137        store.last_run_deleted = Some(deleted);
7138        let _ = store.save(&state.cleanup_policy_path);
7139    }
7140    deleted
7141}
7142
7143// ── Auto-cleanup policy API ───────────────────────────────────────────────────
7144
7145/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
7146async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
7147    let store = state.cleanup_policy.lock().await;
7148    Json(serde_json::json!({
7149        "policy": store.policy,
7150        "last_run_at": store.last_run_at,
7151        "last_run_deleted": store.last_run_deleted,
7152    }))
7153    .into_response()
7154}
7155
7156/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
7157async fn api_save_cleanup_policy(
7158    State(state): State<AppState>,
7159    Json(body): Json<CleanupPolicy>,
7160) -> Response {
7161    // Abort any running task so the new interval takes effect immediately.
7162    {
7163        let mut handle = state.cleanup_task_handle.lock().await;
7164        if let Some(h) = handle.take() {
7165            h.abort();
7166        }
7167    }
7168    {
7169        let mut store = state.cleanup_policy.lock().await;
7170        store.policy = Some(body.clone());
7171        if let Err(e) = store.save(&state.cleanup_policy_path) {
7172            return (
7173                StatusCode::INTERNAL_SERVER_ERROR,
7174                Json(serde_json::json!({"error": e.to_string()})),
7175            )
7176                .into_response();
7177        }
7178    }
7179    if body.enabled {
7180        let handle = spawn_cleanup_policy_task(state.clone());
7181        *state.cleanup_task_handle.lock().await = Some(handle);
7182    }
7183    StatusCode::NO_CONTENT.into_response()
7184}
7185
7186/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
7187async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
7188    let deleted = run_auto_cleanup(&state).await;
7189    Json(serde_json::json!({ "deleted": deleted })).into_response()
7190}
7191
7192/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
7193async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
7194    {
7195        let mut handle = state.cleanup_task_handle.lock().await;
7196        if let Some(h) = handle.take() {
7197            h.abort();
7198        }
7199    }
7200    {
7201        let mut store = state.cleanup_policy.lock().await;
7202        store.policy = None;
7203        let _ = store.save(&state.cleanup_policy_path);
7204    }
7205    StatusCode::NO_CONTENT.into_response()
7206}
7207
7208/// Serve the HTML artifact for a run — view or download.
7209/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
7210/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
7211/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
7212/// Only called for browser views; downloads keep the self-contained inline version.
7213fn swap_inline_chart_js_for_static(html: String) -> String {
7214    let Some(head_end) = html.find("</head>") else {
7215        return html;
7216    };
7217    let Some(script_start) = html[..head_end].rfind("<script") else {
7218        return html;
7219    };
7220    let Some(close_offset) = html[script_start..].find("</script>") else {
7221        return html;
7222    };
7223    let block_end = script_start + close_offset + "</script>".len();
7224    format!(
7225        "{}<script src=\"/static/chart-report.js\"></script>{}",
7226        &html[..script_start],
7227        &html[block_end..]
7228    )
7229}
7230
7231/// current-request Content-Security-Policy nonce check.
7232fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
7233    // Find the first nonce value that was baked in at render time.
7234    let Some(start) = html.find("nonce=\"") else {
7235        // Reports generated before nonce support was added have bare <style> and <script>
7236        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
7237        // the inline blocks — without it the browser blocks all CSS and JS.
7238        return html
7239            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7240            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7241    };
7242    let value_start = start + 7; // len(r#"nonce=""#) == 7
7243    let Some(end_offset) = html[value_start..].find('"') else {
7244        return html.to_owned();
7245    };
7246    let old_nonce = &html[value_start..value_start + end_offset];
7247    html.replace(
7248        &format!("nonce=\"{old_nonce}\""),
7249        &format!("nonce=\"{new_nonce}\""),
7250    )
7251}
7252
7253fn serve_html_artifact(
7254    path: &Path,
7255    wants_download: bool,
7256    csp_nonce: &str,
7257    run_id: &str,
7258    server_mode: bool,
7259) -> Response {
7260    match fs::read_to_string(path) {
7261        Ok(raw) => {
7262            // Patch the saved nonce so inline styles/scripts pass CSP.
7263            let content = patch_html_nonce(&raw, csp_nonce);
7264            if wants_download {
7265                // Keep the self-contained inline version for downloads (opened as file://).
7266                (
7267                    [
7268                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7269                        (
7270                            header::CONTENT_DISPOSITION,
7271                            "attachment; filename=report.html",
7272                        ),
7273                    ],
7274                    content,
7275                )
7276                    .into_response()
7277            } else {
7278                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
7279                // browser caches it after the first view; the HTML response also shrinks.
7280                Html(swap_inline_chart_js_for_static(content)).into_response()
7281            }
7282        }
7283        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7284            let filename = path.file_name().map_or_else(
7285                || "report.html".to_string(),
7286                |n| n.to_string_lossy().into_owned(),
7287            );
7288            let html = LocateFileTemplate {
7289                run_id: run_id.to_owned(),
7290                artifact_type: "html".to_string(),
7291                expected_filename: filename,
7292                server_mode,
7293                csp_nonce: csp_nonce.to_owned(),
7294                version: env!("CARGO_PKG_VERSION"),
7295            }
7296            .render()
7297            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7298            (StatusCode::NOT_FOUND, Html(html)).into_response()
7299        }
7300        Err(err) => {
7301            let filename = path.file_name().map_or_else(
7302                || "report.html".to_string(),
7303                |n| n.to_string_lossy().into_owned(),
7304            );
7305            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7306            let html = ErrorTemplate {
7307                message: msg,
7308                last_report_url: Some("/view-reports".to_string()),
7309                last_report_label: Some("View Reports".to_string()),
7310                run_id: None,
7311                error_code: Some(404),
7312                csp_nonce: csp_nonce.to_owned(),
7313                version: env!("CARGO_PKG_VERSION"),
7314            }
7315            .render()
7316            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7317            (StatusCode::NOT_FOUND, Html(html)).into_response()
7318        }
7319    }
7320}
7321
7322/// Serve the PDF artifact for a run — inline or download.
7323fn serve_pdf_artifact(
7324    path: &Path,
7325    report_title: &str,
7326    run_id: &str,
7327    wants_download: bool,
7328    csp_nonce: &str,
7329) -> Response {
7330    match fs::read(path) {
7331        Ok(bytes) => {
7332            let filename = build_pdf_filename(report_title, run_id);
7333            let disposition = if wants_download {
7334                format!("attachment; filename=\"{filename}\"")
7335            } else {
7336                format!("inline; filename=\"{filename}\"")
7337            };
7338            (
7339                [
7340                    (header::CONTENT_TYPE, "application/pdf".to_string()),
7341                    (header::CONTENT_DISPOSITION, disposition),
7342                ],
7343                bytes,
7344            )
7345                .into_response()
7346        }
7347        Err(err) => {
7348            let filename = path.file_name().map_or_else(
7349                || "report.pdf".to_string(),
7350                |n| n.to_string_lossy().into_owned(),
7351            );
7352            let msg = format!(
7353                "PDF report '{filename}' could not be read.\n\n\
7354                 Error: {err}\n\n\
7355                 If you moved or renamed the output folder, the stored path is now stale. \
7356                 Use 'Open PDF folder' from the results page to browse the output directory."
7357            );
7358            let html = ErrorTemplate {
7359                message: msg,
7360                last_report_url: Some("/view-reports".to_string()),
7361                last_report_label: Some("View Reports".to_string()),
7362                run_id: Some(run_id.to_owned()),
7363                error_code: Some(404),
7364                csp_nonce: csp_nonce.to_owned(),
7365                version: env!("CARGO_PKG_VERSION"),
7366            }
7367            .render()
7368            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7369            (StatusCode::NOT_FOUND, Html(html)).into_response()
7370        }
7371    }
7372}
7373
7374/// Serve the JSON artifact for a run — view or download.
7375fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7376    match fs::read(path) {
7377        Ok(bytes) => {
7378            if wants_download {
7379                (
7380                    [
7381                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7382                        (
7383                            header::CONTENT_DISPOSITION,
7384                            "attachment; filename=result.json",
7385                        ),
7386                    ],
7387                    bytes,
7388                )
7389                    .into_response()
7390            } else {
7391                (
7392                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7393                    bytes,
7394                )
7395                    .into_response()
7396            }
7397        }
7398        Err(err) => {
7399            let filename = path.file_name().map_or_else(
7400                || "result.json".to_string(),
7401                |n| n.to_string_lossy().into_owned(),
7402            );
7403            let msg = format!(
7404                "JSON result '{filename}' could not be read.\n\n\
7405                 Error: {err}\n\n\
7406                 If you moved or renamed the output folder, the stored path is now stale. \
7407                 Use 'Open JSON folder' from the results page to browse the output directory."
7408            );
7409            let html = ErrorTemplate {
7410                message: msg,
7411                last_report_url: Some("/view-reports".to_string()),
7412                last_report_label: Some("View Reports".to_string()),
7413                run_id: None,
7414                error_code: Some(404),
7415                csp_nonce: csp_nonce.to_owned(),
7416                version: env!("CARGO_PKG_VERSION"),
7417            }
7418            .render()
7419            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7420            (StatusCode::NOT_FOUND, Html(html)).into_response()
7421        }
7422    }
7423}
7424
7425/// Recover a `RunArtifacts` from the persisted registry for a run ID.
7426fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7427    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
7428    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
7429    let output_dir = entry
7430        .html_path
7431        .as_ref()
7432        .or(entry.json_path.as_ref())
7433        .or(entry.pdf_path.as_ref())
7434        .or(entry.csv_path.as_ref())
7435        .or(entry.xlsx_path.as_ref())
7436        .and_then(|p| {
7437            let parent = p.parent()?;
7438            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7439            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
7440            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7441                parent.parent().map(PathBuf::from)
7442            } else {
7443                Some(parent.to_path_buf())
7444            }
7445        })
7446        .unwrap_or_default();
7447    // Recover pdf_path: use the persisted one, or look for report.pdf
7448    // adjacent to html/json if only the old entries lack it.
7449    let pdf_path = entry.pdf_path.clone().or_else(|| {
7450        let candidate = output_dir.join("report.pdf");
7451        candidate.exists().then_some(candidate)
7452    });
7453    // csv_path / xlsx_path: persisted paths take precedence; fall back to
7454    // scanning the run directory for files matching the expected patterns so
7455    // that runs created before this feature still surface their artifacts.
7456    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7457        // Check excel/ subfolder (new layout) then root (old layout).
7458        for dir in &[output_dir.join("excel"), output_dir.clone()] {
7459            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7460                entries
7461                    .filter_map(std::result::Result::ok)
7462                    .find(|e| {
7463                        let n = e.file_name();
7464                        let n = n.to_string_lossy();
7465                        n.starts_with("report_") && n.ends_with(ext)
7466                    })
7467                    .map(|e| e.path())
7468            }) {
7469                return Some(p);
7470            }
7471        }
7472        None
7473    };
7474
7475    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7476    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7477    RunArtifacts {
7478        output_dir: output_dir.clone(),
7479        html_path: entry.html_path.clone(),
7480        pdf_path,
7481        json_path: entry.json_path.clone(),
7482        csv_path,
7483        xlsx_path,
7484        scan_config_path: find_scan_config_in_dir(&output_dir),
7485        report_title: entry.project_label.clone(),
7486        result_context: RunResultContext::default(),
7487    }
7488}
7489
7490#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
7491async fn resolve_artifact_set(
7492    state: &AppState,
7493    run_id: &str,
7494    csp_nonce: &str,
7495) -> Result<RunArtifacts, Response> {
7496    let cached = state.artifacts.lock().await.get(run_id).cloned();
7497    if let Some(a) = cached {
7498        return Ok(a);
7499    }
7500    let reg = state.registry.lock().await;
7501    if let Some(entry) = reg.find_by_run_id(run_id) {
7502        return Ok(recover_artifacts_from_registry(entry));
7503    }
7504    drop(reg);
7505    let short_id = &run_id[..run_id.len().min(8)];
7506    let hint = if matches!(
7507        run_id,
7508        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7509    ) {
7510        format!(
7511            " The URL format appears to be reversed \u{2014} \
7512             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7513             Use the View Reports page to navigate to your scan."
7514        )
7515    } else {
7516        " The report may have been deleted or the report directory moved. \
7517         Use View Reports to browse your scan history."
7518            .to_string()
7519    };
7520    let error_html = ErrorTemplate {
7521        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7522        last_report_url: Some("/view-reports".to_string()),
7523        last_report_label: Some("View Reports".to_string()),
7524        run_id: None,
7525        error_code: Some(404),
7526        csp_nonce: csp_nonce.to_owned(),
7527        version: env!("CARGO_PKG_VERSION"),
7528    }
7529    .render()
7530    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7531    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7532}
7533
7534/// Return the path to a run's PDF, queuing background generation when it is missing.
7535///
7536/// Returns `Ok(path)` when the PDF is known (it may still be generating).
7537/// Returns `Err(response)` when there is no JSON source to regenerate from.
7538async fn resolve_or_queue_pdf(
7539    state: &AppState,
7540    pdf_path: Option<PathBuf>,
7541    json_path: Option<PathBuf>,
7542    output_dir: PathBuf,
7543    run_id: &str,
7544    report_title: &str,
7545    csp_nonce: &str,
7546) -> Result<PathBuf, Response> {
7547    if let Some(p) = pdf_path {
7548        return Ok(p);
7549    }
7550    let Some(json_src) = json_path.filter(|p| p.exists()) else {
7551        let msg = "PDF report was not generated for this run. \
7552                   Re-run the analysis with PDF output enabled."
7553            .to_string();
7554        let html = ErrorTemplate {
7555            message: msg,
7556            last_report_url: Some(format!("/runs/html/{run_id}")),
7557            last_report_label: Some("View HTML Report".to_string()),
7558            run_id: Some(run_id.to_string()),
7559            error_code: Some(404),
7560            csp_nonce: csp_nonce.to_string(),
7561            version: env!("CARGO_PKG_VERSION"),
7562        }
7563        .render()
7564        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7565        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7566    };
7567    let pdf_filename = build_pdf_filename(report_title, run_id);
7568    let pdf_dest = output_dir.join(&pdf_filename);
7569    if !pdf_dest.exists() {
7570        // Record the pending path so concurrent requests show the spinner.
7571        {
7572            let mut map = state.artifacts.lock().await;
7573            if let Some(entry) = map.get_mut(run_id) {
7574                entry.pdf_path = Some(pdf_dest.clone());
7575            }
7576        }
7577        {
7578            let mut reg = state.registry.lock().await;
7579            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7580                e.pdf_path = Some(pdf_dest.clone());
7581            }
7582            let _ = reg.save(&state.registry_path);
7583        }
7584        spawn_native_pdf_background(
7585            json_src,
7586            pdf_dest.clone(),
7587            run_id.to_string(),
7588            state.artifacts.clone(),
7589        );
7590    }
7591    Ok(pdf_dest)
7592}
7593
7594/// Self-refreshing "please wait" page shown while the background PDF task is still running.
7595fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7596    let html = format!(
7597        "<!doctype html><html lang=\"en\"><head>\
7598                     <meta charset=utf-8>\
7599                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7600                     <meta http-equiv=\"refresh\" content=\"5\">\
7601                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
7602                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7603                     <style nonce=\"{csp_nonce}\">\
7604                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7605                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7606                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7607                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7608                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7609                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7610                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7611                     background:var(--bg);color:var(--text);}}\
7612                     .top-nav{{position:sticky;top:0;z-index:30;\
7613                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7614                     border-bottom:1px solid rgba(255,255,255,0.12);\
7615                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7616                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7617                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
7618                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7619                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7620                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7621                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7622                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7623                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7624                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7625                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7626                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7627                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7628                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7629                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7630                     justify-content:center;min-height:38px;border-radius:999px;\
7631                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7632                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7633                     .theme-toggle .icon-sun{{display:none;}}\
7634                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7635                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7636                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7637                     display:flex;align-items:center;justify-content:center;\
7638                     min-height:calc(100vh - 56px);}}\
7639                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7640                     .panel{{background:var(--surface);border:1px solid var(--line);\
7641                     border-radius:var(--radius);box-shadow:var(--shadow);\
7642                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7643                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
7644                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
7645                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7646                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7647                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7648                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7649                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7650                     min-height:42px;padding:0 20px;border-radius:14px;\
7651                     border:1px solid var(--line-strong);text-decoration:none;\
7652                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7653                     .back-link:hover{{background:var(--line);}}\
7654                     </style></head>\
7655                     <body>\
7656                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7657                       <a class=\"brand\" href=\"/\">\
7658                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7659                         <div class=\"brand-copy\">\
7660                           <div class=\"brand-title\">OxideSLOC</div>\
7661                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7662                         </div>\
7663                       </a>\
7664                       <div class=\"nav-right\">\
7665                         <a class=\"nav-pill\" href=\"/\">Home</a>\
7666                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7667                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7668                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7669                           <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>\
7670                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7671                           <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>\
7672                         </button>\
7673                       </div>\
7674                     </div></div>\
7675                     <div class=\"page\"><div class=\"panel\">\
7676                       <div class=\"spin-ring\"></div>\
7677                       <h1>Generating PDF\u{2026}</h1>\
7678                       <p>The PDF is being generated from the scan results.<br>\
7679                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7680                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7681                     </div></div>\
7682                     <script nonce=\"{csp_nonce}\">\
7683                     (function(){{\
7684                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7685                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7686                       var t=document.getElementById(\"theme-toggle\");\
7687                       if(t)t.addEventListener(\"click\",function(){{\
7688                         var d=b.classList.toggle(\"dark-theme\");\
7689                         localStorage.setItem(k,d?\"dark\":\"light\");\
7690                       }});\
7691                     }})();\
7692                     </script>\
7693                     </body></html>"
7694    );
7695    Html(html).into_response()
7696}
7697
7698/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7699fn render_error_artifact_html(
7700    message: String,
7701    last_report_url: Option<String>,
7702    last_report_label: Option<String>,
7703    run_id: Option<String>,
7704    error_code: Option<u16>,
7705    csp_nonce: &str,
7706) -> String {
7707    ErrorTemplate {
7708        message,
7709        last_report_url,
7710        last_report_label,
7711        run_id,
7712        error_code,
7713        csp_nonce: csp_nonce.to_owned(),
7714        version: env!("CARGO_PKG_VERSION"),
7715    }
7716    .render()
7717    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7718}
7719
7720/// Read a file and serve it as an attachment download.
7721fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7722    fs::read(path).map_or_else(
7723        |_| StatusCode::NOT_FOUND.into_response(),
7724        |bytes| {
7725            let filename = path.file_name().map_or_else(
7726                || fallback_filename.to_string(),
7727                |n| n.to_string_lossy().into_owned(),
7728            );
7729            (
7730                [
7731                    (header::CONTENT_TYPE, content_type.to_string()),
7732                    (
7733                        header::CONTENT_DISPOSITION,
7734                        format!("attachment; filename=\"{filename}\""),
7735                    ),
7736                ],
7737                bytes,
7738            )
7739                .into_response()
7740        },
7741    )
7742}
7743
7744fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7745    let Some(path) = csv_path else {
7746        let html = render_error_artifact_html(
7747            "CSV report was not generated for this run, or was not recorded in \
7748             the scan registry."
7749                .to_string(),
7750            Some(format!("/runs/html/{run_id}")),
7751            Some("View HTML Report".to_string()),
7752            Some(run_id.to_string()),
7753            Some(404),
7754            csp_nonce,
7755        );
7756        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7757    };
7758    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7759}
7760
7761fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7762    let Some(path) = xlsx_path else {
7763        let html = render_error_artifact_html(
7764            "Excel report was not generated for this run, or was not recorded in \
7765             the scan registry."
7766                .to_string(),
7767            Some(format!("/runs/html/{run_id}")),
7768            Some("View HTML Report".to_string()),
7769            Some(run_id.to_string()),
7770            Some(404),
7771            csp_nonce,
7772        );
7773        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7774    };
7775    serve_binary_download(
7776        &path,
7777        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7778        "report.xlsx",
7779    )
7780}
7781
7782fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7783    let path = artifact_set
7784        .scan_config_path
7785        .as_deref()
7786        .map(std::path::Path::to_path_buf)
7787        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7788        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7789    fs::read(&path).map_or_else(
7790        |_| StatusCode::NOT_FOUND.into_response(),
7791        |bytes| {
7792            (
7793                [
7794                    (
7795                        header::CONTENT_TYPE,
7796                        "application/json; charset=utf-8".to_string(),
7797                    ),
7798                    (
7799                        header::CONTENT_DISPOSITION,
7800                        "attachment; filename=\"scan-config.json\"".to_string(),
7801                    ),
7802                ],
7803                bytes,
7804            )
7805                .into_response()
7806        },
7807    )
7808}
7809
7810/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7811/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7812/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7813/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7814async fn serve_submodule_pdf_arm(
7815    artifact: &str,
7816    artifact_set: RunArtifacts,
7817    wants_download: bool,
7818    run_id: &str,
7819    csp_nonce: &str,
7820) -> Response {
7821    // "sub_benchmark_pdf" → base = "sub_benchmark"
7822    let base = artifact.trim_end_matches("_pdf");
7823    let sub_dir = artifact_set.output_dir.join("submodules");
7824    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7825
7826    if !pdf_path.exists() {
7827        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7828        let derived_safe = base.trim_start_matches("sub_");
7829        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7830            let parent_run = read_json(jp).ok()?;
7831            let sub = parent_run
7832                .submodule_summaries
7833                .iter()
7834                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7835                .clone();
7836            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7837            Some((parent_run, sub, parent_path))
7838        });
7839
7840        if let Some((parent_run, sub, parent_path)) = rebuilt {
7841            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7842            let pp = pdf_path.clone();
7843            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7844        }
7845    }
7846
7847    if !pdf_path.exists() {
7848        let html = render_error_artifact_html(
7849            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7850             enabled."
7851                .to_string(),
7852            Some("/view-reports".to_string()),
7853            Some("View Reports".to_string()),
7854            Some(run_id.to_string()),
7855            Some(404),
7856            csp_nonce,
7857        );
7858        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7859    }
7860
7861    serve_pdf_artifact(
7862        &pdf_path,
7863        &artifact_set.report_title,
7864        run_id,
7865        wants_download,
7866        csp_nonce,
7867    )
7868}
7869
7870fn serve_submodule_arm(
7871    artifact: &str,
7872    artifact_set: &RunArtifacts,
7873    wants_download: bool,
7874    csp_nonce: &str,
7875    run_id: &str,
7876    server_mode: bool,
7877) -> Response {
7878    if artifact.len() > 128
7879        || !artifact
7880            .chars()
7881            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7882    {
7883        return StatusCode::BAD_REQUEST.into_response();
7884    }
7885    let filename = format!("{artifact}.html");
7886    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7887    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7888    let path = if new_layout.exists() {
7889        new_layout
7890    } else {
7891        artifact_set.output_dir.join(&filename)
7892    };
7893    if !path.exists() {
7894        let html = render_error_artifact_html(
7895            format!(
7896                "Sub-report '{artifact}' was not found in the run directory.\n\
7897                 Re-run the analysis with 'Detect and separate git submodules' \
7898                 and HTML output enabled."
7899            ),
7900            Some("/view-reports".to_string()),
7901            Some("View Reports".to_string()),
7902            Some(run_id.to_string()),
7903            Some(404),
7904            csp_nonce,
7905        );
7906        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7907    }
7908    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7909}
7910
7911async fn serve_pdf_arm(
7912    state: &AppState,
7913    artifact_set: RunArtifacts,
7914    wants_download: bool,
7915    run_id: &str,
7916    csp_nonce: &str,
7917) -> Response {
7918    let report_title = artifact_set.report_title.clone();
7919    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7920    let stale_html_name = artifact_set
7921        .html_path
7922        .as_deref()
7923        .and_then(|p| p.file_name())
7924        .map(|n| n.to_string_lossy().into_owned());
7925    let path = match resolve_or_queue_pdf(
7926        state,
7927        artifact_set.pdf_path,
7928        artifact_set.json_path.clone(),
7929        artifact_set.output_dir.clone(),
7930        run_id,
7931        &report_title,
7932        csp_nonce,
7933    )
7934    .await
7935    {
7936        Ok(p) => p,
7937        Err(r) => return r,
7938    };
7939    if !path.exists() {
7940        // Distinguish a stale registry path (folder moved) from an in-progress
7941        // background generation. Only show the locate page when the PDF was
7942        // already recorded in the registry but the file is now missing.
7943        if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
7944            let html = LocateFileTemplate {
7945                run_id: run_id.to_string(),
7946                artifact_type: "pdf".to_string(),
7947                expected_filename,
7948                server_mode: state.server_mode,
7949                csp_nonce: csp_nonce.to_string(),
7950                version: env!("CARGO_PKG_VERSION"),
7951            }
7952            .render()
7953            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7954            return (StatusCode::NOT_FOUND, Html(html)).into_response();
7955        }
7956        return pdf_generating_response(run_id, csp_nonce);
7957    }
7958    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7959}
7960
7961async fn artifact_handler(
7962    State(state): State<AppState>,
7963    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7964    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7965    Query(query): Query<ArtifactQuery>,
7966) -> Response {
7967    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7968        Ok(a) => a,
7969        Err(r) => return r,
7970    };
7971
7972    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7973
7974    match artifact.as_str() {
7975        "html" => {
7976            let Some(path) = artifact_set.html_path else {
7977                return StatusCode::NOT_FOUND.into_response();
7978            };
7979            serve_html_artifact(
7980                &path,
7981                wants_download,
7982                &csp_nonce,
7983                &run_id,
7984                state.server_mode,
7985            )
7986        }
7987        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7988        "json" => {
7989            let Some(path) = artifact_set.json_path else {
7990                let html = render_error_artifact_html(
7991                    "JSON result was not generated for this run, or was not recorded in \
7992                     the scan registry. Re-run the analysis with JSON output enabled."
7993                        .to_string(),
7994                    Some("/view-reports".to_string()),
7995                    Some("View Reports".to_string()),
7996                    Some(run_id.clone()),
7997                    Some(404),
7998                    &csp_nonce,
7999                );
8000                return (StatusCode::NOT_FOUND, Html(html)).into_response();
8001            };
8002            serve_json_artifact(&path, wants_download, &csp_nonce)
8003        }
8004        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
8005        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
8006        "scan-config" => serve_scan_config_arm(&artifact_set),
8007        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
8008            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
8009                .await
8010        }
8011        _ if artifact.starts_with("sub_") => serve_submodule_arm(
8012            &artifact,
8013            &artifact_set,
8014            wants_download,
8015            &csp_nonce,
8016            &run_id,
8017            state.server_mode,
8018        ),
8019        _ => StatusCode::NOT_FOUND.into_response(),
8020    }
8021}
8022
8023// ── History ───────────────────────────────────────────────────────────────────
8024
8025struct SubmoduleLinkRow {
8026    name: String,
8027    url: String,
8028}
8029
8030struct HistoryEntryRow {
8031    run_id: String,
8032    run_id_short: String,
8033    timestamp: String,
8034    timestamp_utc_ms: i64,
8035    project_label: String,
8036    project_path: String,
8037    files_analyzed: u64,
8038    files_skipped: u64,
8039    code_lines: u64,
8040    comment_lines: u64,
8041    blank_lines: u64,
8042    total_physical_lines: u64,
8043    functions: u64,
8044    classes: u64,
8045    variables: u64,
8046    imports: u64,
8047    test_count: u64,
8048    git_branch: String,
8049    git_commit: String,
8050    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
8051    git_commit_long: String,
8052    has_html: bool,
8053    has_json: bool,
8054    has_pdf: bool,
8055    submodule_links: Vec<SubmoduleLinkRow>,
8056    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
8057    submodule_names_csv: String,
8058}
8059
8060/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
8061fn nth_weekday_of_month(
8062    year: i32,
8063    month: u32,
8064    weekday: chrono::Weekday,
8065    n: u32,
8066) -> chrono::NaiveDate {
8067    use chrono::Datelike;
8068    let mut count = 0u32;
8069    let mut day = 1u32;
8070    loop {
8071        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
8072        if d.weekday() == weekday {
8073            count += 1;
8074            if count == n {
8075                return d;
8076            }
8077        }
8078        day += 1;
8079    }
8080}
8081
8082/// Returns true if `dt` falls within US Pacific Daylight Time.
8083/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
8084/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
8085fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
8086    use chrono::{Datelike, TimeZone};
8087    let year = dt.year();
8088    let dst_start = chrono::Utc.from_utc_datetime(
8089        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
8090            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
8091    );
8092    let dst_end = chrono::Utc.from_utc_datetime(
8093        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
8094            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
8095    );
8096    dt >= dst_start && dt < dst_end
8097}
8098
8099fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
8100    if is_pacific_dst(dt) {
8101        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
8102            .format("%Y-%m-%d %H:%M PDT")
8103            .to_string()
8104    } else {
8105        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
8106            .format("%Y-%m-%d %H:%M PST")
8107            .to_string()
8108    }
8109}
8110
8111/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
8112fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
8113    let (offset, tz) = if is_pacific_dst(dt) {
8114        (
8115            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
8116            "PDT",
8117        )
8118    } else {
8119        (
8120            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
8121            "PST",
8122        )
8123    };
8124    format!(
8125        "{} {tz}",
8126        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
8127    )
8128}
8129
8130fn fmt_git_date(iso: &str) -> Option<String> {
8131    chrono::DateTime::parse_from_rfc3339(iso)
8132        .ok()
8133        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
8134}
8135
8136/// Recover the full-length commit SHA for a registry entry whose stored record
8137/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
8138///
8139/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
8140/// is serialized after the per-file records, near the end of the file. We read a
8141/// bounded tail and pick the `git_commit_long` value whose hash begins with the
8142/// known short SHA — this disambiguates the super-repo commit from any submodule
8143/// commits that also appear. Returns `None` if the file is unreadable or no match.
8144fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
8145    use std::io::{Read, Seek, SeekFrom};
8146    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
8147    if short.is_empty() {
8148        return None;
8149    }
8150    let len = std::fs::metadata(path).ok()?.len();
8151    let start = len.saturating_sub(TAIL);
8152    let mut file = std::fs::File::open(path).ok()?;
8153    file.seek(SeekFrom::Start(start)).ok()?;
8154    let mut buf = Vec::new();
8155    file.read_to_end(&mut buf).ok()?;
8156    let text = String::from_utf8_lossy(&buf);
8157    let short_lower = short.to_ascii_lowercase();
8158    let key = "\"git_commit_long\"";
8159    let mut found: Option<String> = None;
8160    let mut cursor = 0usize;
8161    while let Some(idx) = text[cursor..].find(key) {
8162        let after_key = cursor + idx + key.len();
8163        cursor = after_key;
8164        let rest = &text[after_key..];
8165        let Some(colon) = rest.find(':') else { break };
8166        let value_region = rest[colon + 1..].trim_start();
8167        // Skip `null` (or any non-string) values without consuming the next field.
8168        if let Some(open) = value_region.strip_prefix('"')
8169            && let Some(close) = open.find('"')
8170        {
8171            let val = &open[..close];
8172            if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
8173                found = Some(val.to_string());
8174            }
8175        }
8176    }
8177    found
8178}
8179
8180fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
8181    reg.entries
8182        .iter()
8183        .map(|e| {
8184            let submodule_links = {
8185                let mut links: Vec<SubmoduleLinkRow> = vec![];
8186                let sub_dir = e
8187                    .html_path
8188                    .as_ref()
8189                    .and_then(|p| p.parent())
8190                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8191                if let Some(dir) = sub_dir
8192                    && let Ok(rd) = std::fs::read_dir(dir)
8193                {
8194                    for entry_res in rd.flatten() {
8195                        let fname = entry_res.file_name();
8196                        let fname_str = fname.to_string_lossy();
8197                        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8198                            let stem = &fname_str[..fname_str.len() - 5];
8199                            let display = stem[4..].replace('-', " ");
8200                            links.push(SubmoduleLinkRow {
8201                                name: display,
8202                                url: format!("/runs/{stem}/{}", e.run_id),
8203                            });
8204                        }
8205                    }
8206                }
8207                links.sort_by(|a, b| a.name.cmp(&b.name));
8208                links
8209            };
8210            let submodule_names_csv = submodule_links
8211                .iter()
8212                .map(|l| l.name.as_str())
8213                .collect::<Vec<_>>()
8214                .join(",");
8215            HistoryEntryRow {
8216                run_id: e.run_id.clone(),
8217                run_id_short: e
8218                    .run_id
8219                    .split('-')
8220                    .next_back()
8221                    .unwrap_or(&e.run_id)
8222                    .chars()
8223                    .take(7)
8224                    .collect(),
8225                timestamp: fmt_la_time(e.timestamp_utc),
8226                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
8227                project_label: e.project_label.clone(),
8228                project_path: e
8229                    .input_roots
8230                    .first()
8231                    .map(|s| sanitize_path_str(s))
8232                    .unwrap_or_default(),
8233                files_analyzed: e.summary.files_analyzed,
8234                files_skipped: e.summary.files_skipped,
8235                code_lines: e.summary.code_lines,
8236                comment_lines: e.summary.comment_lines,
8237                blank_lines: e.summary.blank_lines,
8238                total_physical_lines: e.summary.total_physical_lines,
8239                functions: e.summary.functions,
8240                classes: e.summary.classes,
8241                variables: e.summary.variables,
8242                imports: e.summary.imports,
8243                test_count: e.summary.test_count,
8244                git_branch: e.git_branch.clone().unwrap_or_default(),
8245                git_commit: e.git_commit.clone().unwrap_or_default(),
8246                git_commit_long: {
8247                    let short = e.git_commit.clone().unwrap_or_default();
8248                    e.git_commit_long
8249                        .clone()
8250                        .filter(|s| !s.is_empty())
8251                        .or_else(|| {
8252                            e.json_path
8253                                .as_ref()
8254                                .and_then(|p| extract_long_commit_from_json(p, &short))
8255                        })
8256                        .unwrap_or(short)
8257                },
8258                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8259                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8260                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8261                submodule_links,
8262                submodule_names_csv,
8263            }
8264        })
8265        .collect()
8266}
8267
8268#[derive(Deserialize, Default)]
8269struct HistoryQuery {
8270    linked: Option<String>,
8271    error: Option<String>,
8272}
8273
8274async fn history_handler(
8275    State(state): State<AppState>,
8276    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8277    Query(query): Query<HistoryQuery>,
8278) -> impl IntoResponse {
8279    // Auto-scan all watched directories before rendering so the list stays fresh.
8280    auto_scan_watched_dirs(&state).await;
8281    let watched_dirs: Vec<String> = {
8282        let wd = state.watched_dirs.lock().await;
8283        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8284    };
8285    let mut entries = {
8286        let reg = state.registry.lock().await;
8287        make_history_rows(&reg)
8288    };
8289    entries.retain(|e| e.has_html);
8290    let total_scans = entries.len();
8291    let linked_count = query
8292        .linked
8293        .as_deref()
8294        .and_then(|s| s.parse::<usize>().ok())
8295        .unwrap_or(0);
8296    let browse_error = query.error.filter(|s| !s.is_empty());
8297    let template = HistoryTemplate {
8298        version: env!("CARGO_PKG_VERSION"),
8299        entries,
8300        total_scans,
8301        linked_count,
8302        browse_error,
8303        watched_dirs,
8304        csp_nonce,
8305        server_mode: state.server_mode,
8306    };
8307    Html(
8308        template
8309            .render()
8310            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8311    )
8312    .into_response()
8313}
8314
8315async fn compare_select_handler(
8316    State(state): State<AppState>,
8317    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8318) -> impl IntoResponse {
8319    auto_scan_watched_dirs(&state).await;
8320    let watched_dirs: Vec<String> = {
8321        let wd = state.watched_dirs.lock().await;
8322        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8323    };
8324    let mut entries = {
8325        let reg = state.registry.lock().await;
8326        make_history_rows(&reg)
8327    };
8328    entries.retain(|e| e.has_json);
8329    let total_scans = entries.len();
8330    let template = CompareSelectTemplate {
8331        version: env!("CARGO_PKG_VERSION"),
8332        entries,
8333        total_scans,
8334        watched_dirs,
8335        csp_nonce,
8336        server_mode: state.server_mode,
8337    };
8338    Html(
8339        template
8340            .render()
8341            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8342    )
8343    .into_response()
8344}
8345
8346// ── Compare ───────────────────────────────────────────────────────────────────
8347
8348#[derive(Deserialize, Default)]
8349struct CompareQuery {
8350    a: Option<String>,
8351    b: Option<String>,
8352    /// Optional submodule name to scope the comparison to one submodule.
8353    sub: Option<String>,
8354    /// "super" to exclude all submodule files and show only the super-repo.
8355    scope: Option<String>,
8356}
8357
8358struct CompareFileDeltaRow {
8359    relative_path: String,
8360    language: String,
8361    status: String,
8362    baseline_code: i64,
8363    current_code: i64,
8364    baseline_code_display: String,
8365    current_code_display: String,
8366    code_delta_str: String,
8367    code_delta_class: String,
8368    comment_delta_str: String,
8369    comment_delta_class: String,
8370    total_delta_str: String,
8371    total_delta_class: String,
8372}
8373
8374/// Recompute `summary_totals` from the current `per_file_records` slice.
8375/// Used when `per_file_records` has been narrowed to a submodule subset.
8376fn recompute_summary_from_records(run: &mut AnalysisRun) {
8377    let mut totals = SummaryTotals::default();
8378    for r in &run.per_file_records {
8379        if r.language.is_some() {
8380            totals.files_analyzed += 1;
8381        }
8382        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8383        totals.code_lines += r.effective_counts.code_lines;
8384        totals.comment_lines += r.effective_counts.comment_lines;
8385        totals.blank_lines += r.effective_counts.blank_lines;
8386        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8387        totals.functions += r.raw_line_categories.functions;
8388        totals.classes += r.raw_line_categories.classes;
8389        totals.variables += r.raw_line_categories.variables;
8390        totals.imports += r.raw_line_categories.imports;
8391        totals.test_count += r.raw_line_categories.test_count;
8392        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8393        totals.test_suite_count += r.raw_line_categories.test_suite_count;
8394        if let Some(cov) = &r.coverage {
8395            totals.coverage_lines_found += u64::from(cov.lines_found);
8396            totals.coverage_lines_hit += u64::from(cov.lines_hit);
8397            totals.coverage_functions_found += u64::from(cov.functions_found);
8398            totals.coverage_functions_hit += u64::from(cov.functions_hit);
8399            totals.coverage_branches_found += u64::from(cov.branches_found);
8400            totals.coverage_branches_hit += u64::from(cov.branches_hit);
8401        }
8402    }
8403    totals.files_considered = totals.files_analyzed;
8404    run.summary_totals = totals;
8405}
8406
8407fn fmt_delta(n: i64) -> String {
8408    if n > 0 {
8409        format!("+{n}")
8410    } else {
8411        format!("{n}")
8412    }
8413}
8414
8415fn delta_class(n: i64) -> &'static str {
8416    use std::cmp::Ordering;
8417    match n.cmp(&0) {
8418        Ordering::Greater => "pos",
8419        Ordering::Less => "neg",
8420        Ordering::Equal => "zero",
8421    }
8422}
8423
8424// ratio/percentage display, precision loss acceptable
8425#[allow(clippy::cast_precision_loss)]
8426fn fmt_pct(delta: i64, baseline: u64) -> String {
8427    if baseline == 0 {
8428        return "—".to_string();
8429    }
8430    #[allow(clippy::cast_precision_loss)]
8431    let pct = (delta as f64 / baseline as f64) * 100.0;
8432    if pct > 0.049 {
8433        format!("+{pct:.1}%")
8434    } else if pct < -0.049 {
8435        format!("{pct:.1}%")
8436    } else {
8437        "±0%".to_string()
8438    }
8439}
8440
8441/// Returns (`display_string`, `css_class`) for a numeric change column cell.
8442fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8443    prev.map_or_else(
8444        || ("—".to_string(), "na"),
8445        |p| {
8446            #[allow(clippy::cast_possible_wrap)]
8447            let d = curr as i64 - p as i64;
8448            (fmt_delta(d), delta_class(d))
8449        },
8450    )
8451}
8452
8453#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
8454fn load_scan_for_compare(
8455    json_path: &std::path::Path,
8456    scan_label: &str,
8457    run_id: &str,
8458    server_mode: bool,
8459    compare_url: &str,
8460    csp_nonce: &str,
8461) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8462    match read_json(json_path) {
8463        Ok(r) => Ok(r),
8464        Err(e) => {
8465            if server_mode {
8466                let html = ErrorTemplate {
8467                    message: format!(
8468                        "Could not load {scan_label} scan data. The scan output folder may have \
8469                         been moved, renamed, or deleted. Re-running the analysis will create \
8470                         fresh comparison data."
8471                    ),
8472                    last_report_url: Some("/compare-scans".to_string()),
8473                    last_report_label: Some("Compare Scans".to_string()),
8474                    run_id: Some(run_id.to_owned()),
8475                    error_code: Some(404),
8476                    csp_nonce: csp_nonce.to_owned(),
8477                    version: env!("CARGO_PKG_VERSION"),
8478                }
8479                .render()
8480                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8481                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8482            }
8483            let msg = format!(
8484                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8485                json_path.display()
8486            );
8487            let folder_hint = output_folder_hint(json_path);
8488            Err(missing_scan_relocate_response(
8489                &msg,
8490                run_id,
8491                &folder_hint,
8492                compare_url,
8493                false,
8494                csp_nonce,
8495            ))
8496        }
8497    }
8498}
8499
8500struct ChurnStats {
8501    new_scope: bool,
8502    scope_flag: bool,
8503    churn_rate_str: String,
8504    churn_rate_class: String,
8505}
8506
8507fn compute_churn_stats(
8508    baseline_code: u64,
8509    current_code: u64,
8510    lines_added: i64,
8511    lines_removed: i64,
8512) -> ChurnStats {
8513    let new_scope = baseline_code == 0 && current_code > 0;
8514    #[allow(clippy::cast_precision_loss)]
8515    let churn_pct = if baseline_code > 0 {
8516        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8517    } else {
8518        0.0
8519    };
8520    #[allow(clippy::cast_precision_loss)]
8521    let scope_flag =
8522        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8523    let churn_rate_str = if new_scope {
8524        "New".to_string()
8525    } else if baseline_code > 0 {
8526        format!("{churn_pct:.1}%")
8527    } else {
8528        "—".to_string()
8529    };
8530    let churn_rate_class = if new_scope || churn_pct > 20.0 {
8531        "high".to_string()
8532    } else if churn_pct > 5.0 {
8533        "med".to_string()
8534    } else {
8535        "low".to_string()
8536    };
8537    ChurnStats {
8538        new_scope,
8539        scope_flag,
8540        churn_rate_str,
8541        churn_rate_class,
8542    }
8543}
8544
8545/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
8546/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
8547/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
8548fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8549    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8550    if !has_data {
8551        return String::new();
8552    }
8553    let base_str = s
8554        .baseline_coverage_line_pct
8555        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8556    let curr_str = s
8557        .current_coverage_line_pct
8558        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8559    let (delta_str, cls) = match s.coverage_line_pct_delta {
8560        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8561        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8562        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8563        None => ("\u{2014}".into(), "zero"),
8564    };
8565    format!(
8566        r#"<div class="delta-card">
8567          <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>
8568          <div class="delta-card-label">Line coverage</div>
8569          <div class="delta-card-from">Before: {base_str}</div>
8570          <div class="delta-card-to">{curr_str}</div>
8571          <span class="delta-card-change {cls}">{delta_str}</span>
8572        </div>"#
8573    )
8574}
8575
8576/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
8577#[allow(clippy::ref_option)]
8578fn narrow_run_pair_by_scope(
8579    mut baseline: AnalysisRun,
8580    mut current: AnalysisRun,
8581    active_sub: &Option<String>,
8582    super_scope: bool,
8583) -> (AnalysisRun, AnalysisRun) {
8584    if let Some(sub_name) = active_sub {
8585        baseline
8586            .per_file_records
8587            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8588        current
8589            .per_file_records
8590            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8591        recompute_summary_from_records(&mut baseline);
8592        recompute_summary_from_records(&mut current);
8593    } else if super_scope {
8594        baseline.per_file_records.retain(|f| f.submodule.is_none());
8595        current.per_file_records.retain(|f| f.submodule.is_none());
8596        recompute_summary_from_records(&mut baseline);
8597        recompute_summary_from_records(&mut current);
8598    }
8599    (baseline, current)
8600}
8601
8602/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
8603#[allow(clippy::ref_option)]
8604fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8605    if let Some(sub_name) = active_sub {
8606        for run in runs.iter_mut() {
8607            run.per_file_records
8608                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8609            recompute_summary_from_records(run);
8610        }
8611    } else if super_scope {
8612        for run in runs.iter_mut() {
8613            run.per_file_records.retain(|f| f.submodule.is_none());
8614            recompute_summary_from_records(run);
8615        }
8616    }
8617}
8618
8619#[allow(clippy::too_many_lines)]
8620async fn compare_handler(
8621    State(state): State<AppState>,
8622    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8623    Query(query): Query<CompareQuery>,
8624) -> impl IntoResponse {
8625    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
8626    // redirect to the history page where the user can select two runs.
8627    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8628        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8629        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8630    };
8631
8632    let (maybe_a, maybe_b) = {
8633        let reg = state.registry.lock().await;
8634        (
8635            reg.find_by_run_id(&run_id_a).cloned(),
8636            reg.find_by_run_id(&run_id_b).cloned(),
8637        )
8638    };
8639
8640    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8641        let html = ErrorTemplate {
8642            message: "One or both run IDs were not found in scan history. \
8643                      The runs may have been deleted or the registry may have been reset."
8644                .to_string(),
8645            last_report_url: Some("/compare-scans".to_string()),
8646            last_report_label: Some("Compare Scans".to_string()),
8647            run_id: None,
8648            error_code: None,
8649            csp_nonce: csp_nonce.clone(),
8650            version: env!("CARGO_PKG_VERSION"),
8651        }
8652        .render()
8653        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8654        return Html(html).into_response();
8655    };
8656
8657    // Ensure older scan is always the baseline.
8658    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8659        (entry_a, entry_b)
8660    } else {
8661        (entry_b, entry_a)
8662    };
8663
8664    // If query params were in the wrong order, redirect to canonical URL so the
8665    // browser always shows the same URL for the same two scans regardless of how
8666    // the user arrived here (Full diff button vs. Compare Scans selection).
8667    if baseline_entry.run_id != run_id_a {
8668        let canonical = format!(
8669            "/compare?a={}&b={}",
8670            baseline_entry.run_id, current_entry.run_id
8671        );
8672        return axum::response::Redirect::to(&canonical).into_response();
8673    }
8674
8675    let (Some(base_json), Some(curr_json)) = (
8676        baseline_entry.json_path.as_ref(),
8677        current_entry.json_path.as_ref(),
8678    ) else {
8679        let html = ErrorTemplate {
8680            message: "Full comparison requires JSON scan data, which was not saved for one or \
8681                      both of these runs. JSON is now always saved for new scans — re-run the \
8682                      affected projects to enable comparisons."
8683                .to_string(),
8684            last_report_url: Some("/compare-scans".to_string()),
8685            last_report_label: Some("Compare Scans".to_string()),
8686            run_id: None,
8687            error_code: None,
8688            csp_nonce: csp_nonce.clone(),
8689            version: env!("CARGO_PKG_VERSION"),
8690        }
8691        .render()
8692        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8693        return Html(html).into_response();
8694    };
8695
8696    let compare_url = format!(
8697        "/compare?a={}&b={}",
8698        baseline_entry.run_id, current_entry.run_id
8699    );
8700
8701    let baseline_run = match load_scan_for_compare(
8702        base_json,
8703        "baseline",
8704        &baseline_entry.run_id,
8705        state.server_mode,
8706        &compare_url,
8707        &csp_nonce,
8708    ) {
8709        Ok(r) => r,
8710        Err(resp) => return resp,
8711    };
8712    let current_run = match load_scan_for_compare(
8713        curr_json,
8714        "current",
8715        &current_entry.run_id,
8716        state.server_mode,
8717        &compare_url,
8718        &csp_nonce,
8719    ) {
8720        Ok(r) => r,
8721        Err(resp) => return resp,
8722    };
8723
8724    let active_submodule = query.sub.clone();
8725    let super_scope_active = query.scope.as_deref() == Some("super");
8726
8727    let submodule_options = baseline_run
8728        .submodule_summaries
8729        .iter()
8730        .chain(current_run.submodule_summaries.iter())
8731        .map(|s| s.name.clone())
8732        .collect::<std::collections::BTreeSet<_>>()
8733        .into_iter()
8734        .collect::<Vec<_>>();
8735    let has_any_submodule_data = !submodule_options.is_empty();
8736
8737    // Narrow per_file_records when a scope is active, then recompute totals.
8738    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8739        baseline_run,
8740        current_run,
8741        &active_submodule,
8742        super_scope_active,
8743    );
8744
8745    let comparison = compute_delta(&effective_baseline, &effective_current);
8746
8747    let file_rows: Vec<CompareFileDeltaRow> = comparison
8748        .file_deltas
8749        .iter()
8750        .map(|d| CompareFileDeltaRow {
8751            relative_path: d.relative_path.clone(),
8752            language: d.language.clone().unwrap_or_else(|| "—".into()),
8753            status: match d.status {
8754                FileChangeStatus::Added => "added".into(),
8755                FileChangeStatus::Removed => "removed".into(),
8756                FileChangeStatus::Modified => "modified".into(),
8757                FileChangeStatus::Unchanged => "unchanged".into(),
8758            },
8759            baseline_code: d.baseline_code,
8760            current_code: d.current_code,
8761            baseline_code_display: if d.status == FileChangeStatus::Added {
8762                "—".into()
8763            } else {
8764                d.baseline_code.to_string()
8765            },
8766            current_code_display: if d.status == FileChangeStatus::Removed {
8767                "—".into()
8768            } else {
8769                d.current_code.to_string()
8770            },
8771            code_delta_str: fmt_delta(d.code_delta),
8772            code_delta_class: delta_class(d.code_delta).into(),
8773            comment_delta_str: fmt_delta(d.comment_delta),
8774            comment_delta_class: delta_class(d.comment_delta).into(),
8775            total_delta_str: fmt_delta(d.total_delta),
8776            total_delta_class: delta_class(d.total_delta).into(),
8777        })
8778        .collect();
8779
8780    let project_path = baseline_entry
8781        .input_roots
8782        .first()
8783        .map(|s| sanitize_path_str(s))
8784        .unwrap_or_default();
8785    let lines_added = sum_added_code_lines(&comparison);
8786    let lines_removed = sum_removed_code_lines(&comparison);
8787    let churn = compute_churn_stats(
8788        comparison.summary.baseline_code,
8789        comparison.summary.current_code,
8790        lines_added,
8791        lines_removed,
8792    );
8793    let s = &comparison.summary;
8794    let template = CompareTemplate {
8795        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8796        version: env!("CARGO_PKG_VERSION"),
8797        project_label: baseline_entry.project_label.clone(),
8798        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8799        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8800        baseline_run_id: baseline_entry.run_id.clone(),
8801        current_run_id: current_entry.run_id.clone(),
8802        baseline_run_id_short: baseline_entry
8803            .run_id
8804            .split('-')
8805            .next_back()
8806            .unwrap_or(&baseline_entry.run_id)
8807            .chars()
8808            .take(7)
8809            .collect(),
8810        current_run_id_short: current_entry
8811            .run_id
8812            .split('-')
8813            .next_back()
8814            .unwrap_or(&current_entry.run_id)
8815            .chars()
8816            .take(7)
8817            .collect(),
8818        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8819        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8820        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8821        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8822        project_path: project_path.clone(),
8823        baseline_code: s.baseline_code,
8824        current_code: s.current_code,
8825        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8826        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8827        baseline_files: s.baseline_files,
8828        current_files: s.current_files,
8829        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8830        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8831        baseline_comments: s.baseline_comments,
8832        current_comments: s.current_comments,
8833        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8834        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8835        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8836        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8837        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8838        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8839        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8840        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8841        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8842        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8843        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8844        code_lines_added: lines_added,
8845        code_lines_removed: lines_removed,
8846        code_lines_modified: sum_modified_code_lines(&comparison),
8847        code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8848        code_lines_total: lines_added
8849            + lines_removed
8850            + sum_modified_code_lines(&comparison)
8851            + sum_unmodified_code_lines(&comparison),
8852        new_scope: churn.new_scope,
8853        churn_rate_str: churn.churn_rate_str,
8854        churn_rate_class: churn.churn_rate_class,
8855        scope_flag: churn.scope_flag,
8856        files_added: comparison.files_added,
8857        files_removed: comparison.files_removed,
8858        files_modified: comparison.files_modified,
8859        files_unchanged: comparison.files_unchanged,
8860        files_total: comparison.files_total,
8861        file_rows,
8862        baseline_git_author: baseline_entry.git_author.clone(),
8863        current_git_author: current_entry.git_author.clone(),
8864        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8865        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8866        baseline_git_tags: baseline_entry.git_tags.clone(),
8867        current_git_tags: current_entry.git_tags.clone(),
8868        baseline_git_commit_date: baseline_entry
8869            .git_commit_date
8870            .as_deref()
8871            .and_then(fmt_git_date),
8872        current_git_commit_date: current_entry
8873            .git_commit_date
8874            .as_deref()
8875            .and_then(fmt_git_date),
8876        project_name: project_path
8877            .rsplit(['/', '\\'])
8878            .find(|s| !s.is_empty())
8879            .unwrap_or(&project_path)
8880            .to_string(),
8881        submodule_options,
8882        has_any_submodule_data,
8883        active_submodule,
8884        super_scope_active,
8885        toast_assets: sloc_toast_assets(&csp_nonce),
8886        csp_nonce,
8887        coverage_delta_card: build_coverage_delta_card(s),
8888        baseline_test_count: effective_baseline.summary_totals.test_count,
8889        current_test_count: effective_current.summary_totals.test_count,
8890        baseline_coverage_pct: s.baseline_coverage_line_pct,
8891        current_coverage_pct: s.current_coverage_line_pct,
8892    };
8893
8894    Html(
8895        template
8896            .render()
8897            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8898    )
8899    .into_response()
8900}
8901
8902// ── Badge endpoint ────────────────────────────────────────────────────────────
8903// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8904// pages, Jira descriptions, etc.
8905//
8906// GET /badge/<metric>?label=<override>&color=<hex>
8907// Metrics: code-lines  files  comment-lines  blank-lines
8908
8909fn format_number(n: u64) -> String {
8910    let s = n.to_string();
8911    let mut out = String::with_capacity(s.len() + s.len() / 3);
8912    let len = s.len();
8913    for (i, c) in s.chars().enumerate() {
8914        if i > 0 && (len - i).is_multiple_of(3) {
8915            out.push(',');
8916        }
8917        out.push(c);
8918    }
8919    out
8920}
8921
8922const fn badge_char_width(c: char) -> f64 {
8923    match c {
8924        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8925        'm' | 'w' => 9.0,
8926        ' ' => 4.0,
8927        _ => 6.5,
8928    }
8929}
8930
8931#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8932fn badge_text_px(text: &str) -> u32 {
8933    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8934}
8935
8936fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8937    let lw = badge_text_px(label) + 20;
8938    let rw = badge_text_px(value) + 20;
8939    let total = lw + rw;
8940    let lx = lw / 2;
8941    let rx = lw + rw / 2;
8942    let le = escape_html(label);
8943    let ve = escape_html(value);
8944    let ce = escape_html(color);
8945    format!(
8946        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8947  <rect width="{total}" height="20" fill="#555"/>
8948  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8949  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8950    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8951    <text x="{lx}" y="13">{le}</text>
8952    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8953    <text x="{rx}" y="13">{ve}</text>
8954  </g>
8955</svg>"##
8956    )
8957}
8958
8959#[derive(Deserialize)]
8960struct BadgeQuery {
8961    label: Option<String>,
8962    color: Option<String>,
8963}
8964
8965async fn badge_handler(
8966    State(state): State<AppState>,
8967    AxumPath(metric): AxumPath<String>,
8968    Query(query): Query<BadgeQuery>,
8969) -> Response {
8970    let entry = {
8971        let reg = state.registry.lock().await;
8972        reg.entries.first().cloned()
8973    };
8974
8975    let Some(entry) = entry else {
8976        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8977        return (
8978            [
8979                (header::CONTENT_TYPE, "image/svg+xml"),
8980                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8981            ],
8982            svg,
8983        )
8984            .into_response();
8985    };
8986
8987    let (default_label, value, default_color) = match metric.as_str() {
8988        "code-lines" => (
8989            "code lines",
8990            format_number(entry.summary.code_lines),
8991            "#4a78ee",
8992        ),
8993        "files" => (
8994            "files analyzed",
8995            format_number(entry.summary.files_analyzed),
8996            "#4a9862",
8997        ),
8998        "comment-lines" => (
8999            "comment lines",
9000            format_number(entry.summary.comment_lines),
9001            "#b35428",
9002        ),
9003        "blank-lines" => (
9004            "blank lines",
9005            format_number(entry.summary.blank_lines),
9006            "#7a5db0",
9007        ),
9008        _ => return StatusCode::NOT_FOUND.into_response(),
9009    };
9010
9011    let label = query.label.as_deref().unwrap_or(default_label);
9012    let color = query.color.as_deref().unwrap_or(default_color);
9013    let svg = render_badge_svg(label, &value, color);
9014
9015    (
9016        [
9017            (header::CONTENT_TYPE, "image/svg+xml"),
9018            (header::CACHE_CONTROL, "no-cache, max-age=0"),
9019        ],
9020        svg,
9021    )
9022        .into_response()
9023}
9024
9025// ── Metrics API ───────────────────────────────────────────────────────────────
9026// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
9027// Confluence automation, Jira webhooks, etc.
9028//
9029// GET /api/metrics/latest
9030// GET /api/metrics/<run_id>
9031
9032#[derive(Serialize)]
9033struct ApiCoverageBlock {
9034    lines_found: u64,
9035    lines_hit: u64,
9036    line_pct: f64,
9037    functions_found: u64,
9038    functions_hit: u64,
9039    function_pct: f64,
9040    branches_found: u64,
9041    branches_hit: u64,
9042    branch_pct: f64,
9043}
9044
9045#[derive(Serialize)]
9046struct ApiMetricsResponse {
9047    run_id: String,
9048    timestamp: String,
9049    project: String,
9050    summary: ApiSummaryPayload,
9051    languages: Vec<ApiLanguageRow>,
9052    #[serde(skip_serializing_if = "Option::is_none")]
9053    coverage: Option<ApiCoverageBlock>,
9054}
9055
9056#[derive(Serialize)]
9057struct ApiSummaryPayload {
9058    files_analyzed: u64,
9059    files_skipped: u64,
9060    code_lines: u64,
9061    comment_lines: u64,
9062    blank_lines: u64,
9063    total_physical_lines: u64,
9064    functions: u64,
9065    classes: u64,
9066    variables: u64,
9067    imports: u64,
9068}
9069
9070#[derive(Serialize)]
9071struct ApiLanguageRow {
9072    name: String,
9073    files: u64,
9074    code_lines: u64,
9075    comment_lines: u64,
9076    blank_lines: u64,
9077    functions: u64,
9078    classes: u64,
9079    variables: u64,
9080    imports: u64,
9081}
9082
9083async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
9084    let entry = {
9085        let reg = state.registry.lock().await;
9086        reg.entries.first().cloned()
9087    };
9088    entry.map_or_else(
9089        || error::not_found("no scans recorded yet"),
9090        |e| build_metrics_response(&e),
9091    )
9092}
9093
9094async fn api_metrics_run_handler(
9095    State(state): State<AppState>,
9096    AxumPath(run_id): AxumPath<String>,
9097) -> Response {
9098    let entry = {
9099        let reg = state.registry.lock().await;
9100        reg.find_by_run_id(&run_id).cloned()
9101    };
9102    entry.map_or_else(
9103        || error::not_found("run not found"),
9104        |e| build_metrics_response(&e),
9105    )
9106}
9107
9108fn build_metrics_response(entry: &RegistryEntry) -> Response {
9109    let languages: Vec<ApiLanguageRow> = entry
9110        .json_path
9111        .as_ref()
9112        .and_then(|p| read_json(p).ok())
9113        .map(|run| {
9114            run.totals_by_language
9115                .iter()
9116                .map(|l| ApiLanguageRow {
9117                    name: l.language.display_name().to_string(),
9118                    files: l.files,
9119                    code_lines: l.code_lines,
9120                    comment_lines: l.comment_lines,
9121                    blank_lines: l.blank_lines,
9122                    functions: l.functions,
9123                    classes: l.classes,
9124                    variables: l.variables,
9125                    imports: l.imports,
9126                })
9127                .collect()
9128        })
9129        .unwrap_or_default();
9130
9131    let s = &entry.summary;
9132    let coverage = if s.coverage_lines_found > 0 {
9133        let pct = |hit: u64, found: u64| -> f64 {
9134            if found == 0 {
9135                0.0
9136            } else {
9137                #[allow(clippy::cast_precision_loss)]
9138                let v = (hit as f64 / found as f64) * 100.0;
9139                (v * 10.0).round() / 10.0
9140            }
9141        };
9142        Some(ApiCoverageBlock {
9143            lines_found: s.coverage_lines_found,
9144            lines_hit: s.coverage_lines_hit,
9145            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
9146            functions_found: s.coverage_functions_found,
9147            functions_hit: s.coverage_functions_hit,
9148            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
9149            branches_found: s.coverage_branches_found,
9150            branches_hit: s.coverage_branches_hit,
9151            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
9152        })
9153    } else {
9154        None
9155    };
9156    Json(ApiMetricsResponse {
9157        run_id: entry.run_id.clone(),
9158        timestamp: entry.timestamp_utc.to_rfc3339(),
9159        project: entry.project_label.clone(),
9160        summary: ApiSummaryPayload {
9161            files_analyzed: s.files_analyzed,
9162            files_skipped: s.files_skipped,
9163            code_lines: s.code_lines,
9164            comment_lines: s.comment_lines,
9165            blank_lines: s.blank_lines,
9166            total_physical_lines: s.total_physical_lines,
9167            functions: s.functions,
9168            classes: s.classes,
9169            variables: s.variables,
9170            imports: s.imports,
9171        },
9172        languages,
9173        coverage,
9174    })
9175    .into_response()
9176}
9177
9178// ── Project history API ───────────────────────────────────────────────────────
9179// Protected. Called by the wizard JS when the project path changes, so the UI
9180// can show a "scanned N times before" badge without a full page reload.
9181//
9182// GET /api/project-history?path=<project_root>
9183
9184#[derive(Deserialize)]
9185struct ProjectHistoryQuery {
9186    path: Option<String>,
9187}
9188
9189#[derive(Serialize)]
9190struct ProjectHistoryResponse {
9191    scan_count: usize,
9192    last_scan_id: Option<String>,
9193    last_scan_timestamp: Option<String>,
9194    last_scan_code_lines: Option<u64>,
9195    last_git_branch: Option<String>,
9196    last_git_commit: Option<String>,
9197}
9198
9199/// Return true if `entry` matches either an exact root path or an upload-staging
9200/// path with the same project name (needed because each upload gets a fresh UUID dir).
9201fn entry_matches_project(
9202    entry: &RegistryEntry,
9203    root_str: &str,
9204    upload_root: &str,
9205    upload_name_suffix: Option<&str>,
9206) -> bool {
9207    if entry.input_roots.iter().any(|r| r == root_str) {
9208        return true;
9209    }
9210    if let Some(suffix) = upload_name_suffix {
9211        return entry
9212            .input_roots
9213            .iter()
9214            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
9215    }
9216    false
9217}
9218
9219async fn project_history_handler(
9220    State(state): State<AppState>,
9221    Query(query): Query<ProjectHistoryQuery>,
9222) -> Response {
9223    let path = query.path.unwrap_or_default();
9224    let resolved = resolve_input_path(&path);
9225    let root_str = resolved.to_string_lossy().replace('\\', "/");
9226
9227    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
9228    // The UUID is freshly generated for every upload, so an exact root_str match never finds
9229    // previous scans of the same project. Fall back to matching by project name within the
9230    // uploads staging directory so Scan History populates correctly across uploads.
9231    let upload_root = std::env::temp_dir()
9232        .join("oxide-sloc-uploads")
9233        .to_string_lossy()
9234        .replace('\\', "/");
9235    let upload_name_suffix: Option<String> =
9236        if state.server_mode && root_str.starts_with(&upload_root) {
9237            resolved
9238                .file_name()
9239                .and_then(|n| n.to_str())
9240                .map(|name| format!("/{name}"))
9241        } else {
9242            None
9243        };
9244    let suffix_ref = upload_name_suffix.as_deref();
9245
9246    let entries: Vec<_> = {
9247        let reg = state.registry.lock().await;
9248        reg.entries
9249            .iter()
9250            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9251            .cloned()
9252            .collect()
9253    };
9254    let scan_count = entries.len();
9255    let last = entries.first();
9256    let last_scan_id = last.map(|e| e.run_id.clone());
9257    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9258    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9259    let last_git_branch = last.and_then(|e| e.git_branch.clone());
9260    let last_git_commit = last.and_then(|e| e.git_commit.clone());
9261
9262    Json(ProjectHistoryResponse {
9263        scan_count,
9264        last_scan_id,
9265        last_scan_timestamp,
9266        last_scan_code_lines,
9267        last_git_branch,
9268        last_git_commit,
9269    })
9270    .into_response()
9271}
9272
9273// ── Metrics history API ───────────────────────────────────────────────────────
9274// Protected. Returns a JSON array of lightweight scan snapshots for plotting
9275// trend charts.
9276//
9277// GET /api/metrics/history?root=<path>&limit=<n>
9278
9279#[derive(Deserialize)]
9280struct MetricsHistoryQuery {
9281    root: Option<String>,
9282    limit: Option<usize>,
9283    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
9284    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
9285    submodule: Option<String>,
9286}
9287
9288#[derive(Serialize)]
9289struct MetricsSubmoduleLink {
9290    name: String,
9291    url: String,
9292}
9293
9294#[derive(Serialize)]
9295struct MetricsHistoryEntry {
9296    run_id: String,
9297    run_id_short: String,
9298    timestamp: String,
9299    commit: Option<String>,
9300    branch: Option<String>,
9301    tags: Vec<String>,
9302    nearest_tag: Option<String>,
9303    code_lines: u64,
9304    comment_lines: u64,
9305    blank_lines: u64,
9306    physical_lines: u64,
9307    files_analyzed: u64,
9308    files_skipped: u64,
9309    test_count: u64,
9310    project_label: String,
9311    html_url: Option<String>,
9312    has_pdf: bool,
9313    submodule_links: Vec<MetricsSubmoduleLink>,
9314    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
9315    #[serde(skip_serializing_if = "Option::is_none")]
9316    coverage_line_pct: Option<f64>,
9317}
9318
9319fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9320    let mut links: Vec<MetricsSubmoduleLink> = vec![];
9321    let sub_dir = e
9322        .html_path
9323        .as_ref()
9324        .and_then(|p| p.parent())
9325        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9326    let Some(dir) = sub_dir else { return links };
9327    let Ok(rd) = std::fs::read_dir(dir) else {
9328        return links;
9329    };
9330    for entry_res in rd.flatten() {
9331        let fname = entry_res.file_name();
9332        let fname_str = fname.to_string_lossy();
9333        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9334            let stem = &fname_str[..fname_str.len() - 5];
9335            let display = stem[4..].replace('-', " ");
9336            links.push(MetricsSubmoduleLink {
9337                name: display,
9338                url: format!("/runs/{stem}/{}", e.run_id),
9339            });
9340        }
9341    }
9342    links.sort_by(|a, b| a.name.cmp(&b.name));
9343    links
9344}
9345
9346fn apply_submodule_filter(
9347    base: MetricsHistoryEntry,
9348    filter: &str,
9349    e: &sloc_core::history::RegistryEntry,
9350) -> Option<MetricsHistoryEntry> {
9351    let json_path = e.json_path.as_ref()?;
9352    let json_str = std::fs::read_to_string(json_path).ok()?;
9353    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9354    let sub = run
9355        .submodule_summaries
9356        .iter()
9357        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9358    let safe = sanitize_project_label(&sub.name);
9359    let artifact_key = format!("sub_{safe}");
9360    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9361        || base.html_url.clone(),
9362        |run_dir| {
9363            let sub_path = run_dir.join(format!("{artifact_key}.html"));
9364            if sub_path.exists() {
9365                Some(format!("/runs/{artifact_key}/{}", e.run_id))
9366            } else {
9367                base.html_url.clone()
9368            }
9369        },
9370    );
9371
9372    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
9373    // basic SLOC totals, so test_count and coverage must be computed from file records.
9374    let sub_files: Vec<_> = run
9375        .per_file_records
9376        .iter()
9377        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9378        .collect();
9379    let test_count: u64 = sub_files
9380        .iter()
9381        .map(|r| r.raw_line_categories.test_count)
9382        .sum();
9383    #[allow(clippy::cast_precision_loss)]
9384    let coverage_line_pct: Option<f64> = {
9385        let found: u64 = sub_files
9386            .iter()
9387            .filter_map(|r| r.coverage.as_ref())
9388            .map(|c| u64::from(c.lines_found))
9389            .sum();
9390        let hit: u64 = sub_files
9391            .iter()
9392            .filter_map(|r| r.coverage.as_ref())
9393            .map(|c| u64::from(c.lines_hit))
9394            .sum();
9395        if found > 0 {
9396            let pct = (hit as f64 / found as f64) * 100.0;
9397            Some((pct * 10.0).round() / 10.0)
9398        } else {
9399            None
9400        }
9401    };
9402
9403    Some(MetricsHistoryEntry {
9404        code_lines: sub.code_lines,
9405        comment_lines: sub.comment_lines,
9406        blank_lines: sub.blank_lines,
9407        physical_lines: sub.total_physical_lines,
9408        files_analyzed: sub.files_analyzed,
9409        files_skipped: 0,
9410        test_count,
9411        html_url: sub_html_url,
9412        has_pdf: false,
9413        submodule_links: vec![],
9414        coverage_line_pct,
9415        ..base
9416    })
9417}
9418
9419#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
9420async fn api_metrics_history_handler(
9421    State(state): State<AppState>,
9422    Query(query): Query<MetricsHistoryQuery>,
9423) -> Response {
9424    let limit = query.limit.unwrap_or(50).min(500);
9425    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9426
9427    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9428        let reg = state.registry.lock().await;
9429        reg.entries
9430            .iter()
9431            .filter(|e| {
9432                query.root.as_ref().is_none_or(|root| {
9433                    let resolved = resolve_input_path(root);
9434                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9435                    e.input_roots.iter().any(|r| r == &root_str)
9436                })
9437            })
9438            .take(limit)
9439            .cloned()
9440            .collect()
9441    };
9442
9443    let entries: Vec<MetricsHistoryEntry> = candidate_entries
9444        .into_iter()
9445        .filter_map(|e| {
9446            let tags = e
9447                .git_tags
9448                .as_deref()
9449                .map(|s| {
9450                    s.split(',')
9451                        .map(|t| t.trim().to_string())
9452                        .filter(|t| !t.is_empty())
9453                        .collect()
9454                })
9455                .unwrap_or_default();
9456            let html_url = e
9457                .html_path
9458                .as_ref()
9459                .filter(|p| p.exists())
9460                .map(|_| format!("/runs/html/{}", e.run_id));
9461            let nearest_tag = e.git_nearest_tag.clone();
9462            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9463            let run_id_short: String = e
9464                .run_id
9465                .split('-')
9466                .next_back()
9467                .unwrap_or(&e.run_id)
9468                .chars()
9469                .take(7)
9470                .collect();
9471            let submodule_links = build_entry_submodule_links(&e);
9472            #[allow(clippy::cast_precision_loss)]
9473            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9474                let pct = (e.summary.coverage_lines_hit as f64
9475                    / e.summary.coverage_lines_found as f64)
9476                    * 100.0;
9477                Some((pct * 10.0).round() / 10.0)
9478            } else {
9479                None
9480            };
9481            let base = MetricsHistoryEntry {
9482                run_id: e.run_id.clone(),
9483                run_id_short,
9484                timestamp: e.timestamp_utc.to_rfc3339(),
9485                commit: e.git_commit.clone(),
9486                branch: e.git_branch.clone(),
9487                tags,
9488                nearest_tag,
9489                code_lines: e.summary.code_lines,
9490                comment_lines: e.summary.comment_lines,
9491                blank_lines: e.summary.blank_lines,
9492                physical_lines: e.summary.total_physical_lines,
9493                files_analyzed: e.summary.files_analyzed,
9494                files_skipped: e.summary.files_skipped,
9495                test_count: e.summary.test_count,
9496                project_label: e.project_label.clone(),
9497                html_url,
9498                has_pdf,
9499                submodule_links,
9500                coverage_line_pct,
9501            };
9502            if let Some(ref filter) = submodule_filter {
9503                apply_submodule_filter(base, filter, &e)
9504            } else {
9505                Some(base)
9506            }
9507        })
9508        .collect();
9509
9510    Json(entries).into_response()
9511}
9512
9513/// One scan's code churn versus the previous scan of the same project.
9514#[derive(Serialize)]
9515struct ChurnEntry {
9516    run_id: String,
9517    added: i64,
9518    removed: i64,
9519    modified: i64,
9520    unmodified: i64,
9521}
9522
9523// GET /api/metrics/churn?root=<path>&limit=<n>
9524// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
9525// comparing each scan to the previous scan of the same project. Loads per-file JSON
9526// artifacts, so it is intended for export-time use rather than every page load.
9527async fn api_metrics_churn_handler(
9528    State(state): State<AppState>,
9529    Query(query): Query<MetricsHistoryQuery>,
9530) -> Response {
9531    let limit = query.limit.unwrap_or(200).min(500);
9532    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9533        let reg = state.registry.lock().await;
9534        reg.entries
9535            .iter()
9536            .filter(|e| {
9537                query.root.as_ref().is_none_or(|root| {
9538                    let resolved = resolve_input_path(root);
9539                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9540                    e.input_roots.iter().any(|r| r == &root_str)
9541                })
9542            })
9543            .take(limit)
9544            .cloned()
9545            .collect()
9546    };
9547    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9548        std::collections::HashMap::new();
9549    for e in candidate_entries {
9550        by_project
9551            .entry(e.project_label.clone())
9552            .or_default()
9553            .push(e);
9554    }
9555    let mut out: Vec<ChurnEntry> = Vec::new();
9556    for (_proj, mut entries) in by_project {
9557        entries.sort_by_key(|e| e.timestamp_utc);
9558        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9559        for e in &entries {
9560            let curr = e
9561                .json_path
9562                .as_ref()
9563                .and_then(|path| sloc_core::read_json(path).ok());
9564            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9565                let cmp = sloc_core::compute_delta(prev, cur);
9566                out.push(ChurnEntry {
9567                    run_id: e.run_id.clone(),
9568                    added: sum_added_code_lines(&cmp),
9569                    removed: sum_removed_code_lines(&cmp),
9570                    modified: sum_modified_code_lines(&cmp),
9571                    unmodified: sum_unmodified_code_lines(&cmp),
9572                });
9573            } else {
9574                out.push(ChurnEntry {
9575                    run_id: e.run_id.clone(),
9576                    added: 0,
9577                    removed: 0,
9578                    modified: 0,
9579                    unmodified: 0,
9580                });
9581            }
9582            if curr.is_some() {
9583                prev_run = curr;
9584            }
9585        }
9586    }
9587    Json(out).into_response()
9588}
9589
9590// GET /api/metrics/submodules?root=<path>
9591// Returns the union of distinct submodule names found across all saved scan JSON artifacts
9592// for the given project root (or all roots if omitted).
9593#[derive(Deserialize)]
9594struct MetricsSubmodulesQuery {
9595    root: Option<String>,
9596}
9597
9598#[derive(Serialize)]
9599struct SubmoduleEntry {
9600    name: String,
9601    relative_path: String,
9602}
9603
9604async fn api_metrics_submodules_handler(
9605    State(state): State<AppState>,
9606    Query(query): Query<MetricsSubmodulesQuery>,
9607) -> Response {
9608    let json_paths: Vec<std::path::PathBuf> = {
9609        let reg = state.registry.lock().await;
9610        reg.entries
9611            .iter()
9612            .filter(|e| {
9613                query.root.as_ref().is_none_or(|root| {
9614                    let resolved = resolve_input_path(root);
9615                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9616                    e.input_roots.iter().any(|r| r == &root_str)
9617                })
9618            })
9619            .filter_map(|e| e.json_path.clone())
9620            .collect()
9621    };
9622
9623    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9624    let mut result: Vec<SubmoduleEntry> = Vec::new();
9625
9626    for path in &json_paths {
9627        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9628            continue;
9629        };
9630        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9631            continue;
9632        };
9633        for sub in &run.submodule_summaries {
9634            if seen.insert(sub.name.clone()) {
9635                result.push(SubmoduleEntry {
9636                    name: sub.name.clone(),
9637                    relative_path: sub.relative_path.clone(),
9638                });
9639            }
9640        }
9641    }
9642
9643    result.sort_by(|a, b| a.name.cmp(&b.name));
9644    Json(result).into_response()
9645}
9646
9647// ── CI ingest endpoint ────────────────────────────────────────────────────────
9648// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
9649// server stores and displays results without cloning or scanning anything itself.
9650//
9651// POST /api/ingest?label=<optional_display_name>
9652// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
9653// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
9654
9655#[derive(Deserialize)]
9656struct IngestQuery {
9657    label: Option<String>,
9658}
9659
9660#[derive(Serialize)]
9661struct IngestResponse {
9662    run_id: String,
9663    view_url: String,
9664}
9665
9666async fn api_ingest_handler(
9667    State(state): State<AppState>,
9668    Query(q): Query<IngestQuery>,
9669    Json(run): Json<sloc_core::AnalysisRun>,
9670) -> Response {
9671    let label = q.label.unwrap_or_else(|| {
9672        run.input_roots
9673            .first()
9674            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9675    });
9676
9677    let label_for_task = label.clone();
9678    let result = tokio::task::spawn_blocking(move || {
9679        let html = render_html(&run)?;
9680        let run_id = run.tool.run_id.clone();
9681        let run_id_safe = run_id.len() <= 128
9682            && !run_id.is_empty()
9683            && run_id
9684                .chars()
9685                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9686        if !run_id_safe {
9687            anyhow::bail!(
9688                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9689            );
9690        }
9691        let project_label = sanitize_project_label(&label_for_task);
9692        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9693        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9694            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9695            _ => project_label,
9696        };
9697        let (artifacts, _pending_pdf) = persist_run_artifacts(
9698            &run,
9699            &html,
9700            &output_dir,
9701            &label_for_task,
9702            &file_stem,
9703            RunResultContext::default(),
9704        )?;
9705        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9706    })
9707    .await;
9708
9709    match result {
9710        Ok(Ok((run_id, artifacts, run))) => {
9711            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9712            (
9713                StatusCode::CREATED,
9714                Json(IngestResponse {
9715                    view_url: format!("/view-reports?run_id={run_id}"),
9716                    run_id,
9717                }),
9718            )
9719                .into_response()
9720        }
9721        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9722        Err(e) => error::internal(&format!("{e}")),
9723    }
9724}
9725
9726// ── Multi-compare page ────────────────────────────────────────────────────────
9727// GET /multi-compare?runs=id1,id2,id3,...
9728
9729fn html_escape(s: &str) -> String {
9730    s.replace('&', "&amp;")
9731        .replace('<', "&lt;")
9732        .replace('>', "&gt;")
9733        .replace('"', "&quot;")
9734}
9735
9736#[allow(clippy::cast_precision_loss)]
9737fn fmt_num(n: i64) -> String {
9738    let a = n.unsigned_abs();
9739    if a >= 1_000_000 {
9740        let v = n as f64 / 1_000_000.0;
9741        let s = format!("{v:.1}");
9742        format!("{}M", s.trim_end_matches(".0"))
9743    } else if a >= 10_000 {
9744        let v = n as f64 / 1_000.0;
9745        let s = format!("{v:.1}");
9746        format!("{}K", s.trim_end_matches(".0"))
9747    } else {
9748        let sign = if n < 0 { "-" } else { "" };
9749        if a < 1_000 {
9750            return format!("{sign}{a}");
9751        }
9752        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9753    }
9754}
9755
9756fn fmt_comma(n: i64) -> String {
9757    let sign = if n < 0 { "-" } else { "" };
9758    let a = n.unsigned_abs();
9759    if a < 1_000 {
9760        return format!("{sign}{a}");
9761    }
9762    let s = a.to_string();
9763    let bytes = s.as_bytes();
9764    let len = bytes.len();
9765    let mut out = String::with_capacity(len + len / 3);
9766    for (i, &b) in bytes.iter().enumerate() {
9767        if i > 0 && (len - i).is_multiple_of(3) {
9768            out.push(',');
9769        }
9770        out.push(b as char);
9771    }
9772    format!("{sign}{out}")
9773}
9774
9775/// Insert thousands separators into the integer portion of a number's textual form.
9776///
9777/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9778/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9779/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9780/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9781fn group_thousands(s: &str) -> String {
9782    let (sign, rest) = match s.as_bytes().first() {
9783        Some(b'-') => ("-", &s[1..]),
9784        Some(b'+') => ("+", &s[1..]),
9785        _ => ("", s),
9786    };
9787    let (int_part, frac_part) = match rest.split_once('.') {
9788        Some((i, f)) => (i, Some(f)),
9789        None => (rest, None),
9790    };
9791    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9792        return s.to_string();
9793    }
9794    let bytes = int_part.as_bytes();
9795    let len = bytes.len();
9796    let mut grouped = String::with_capacity(len + len / 3);
9797    for (i, &b) in bytes.iter().enumerate() {
9798        if i > 0 && (len - i).is_multiple_of(3) {
9799            grouped.push(',');
9800        }
9801        grouped.push(b as char);
9802    }
9803    frac_part.map_or_else(
9804        || format!("{sign}{grouped}"),
9805        |f| format!("{sign}{grouped}.{f}"),
9806    )
9807}
9808
9809/// Custom Askama filters available to templates in this crate.
9810mod filters {
9811    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9812    // (a `&self` `execute` method returning `Result`), not on our own source.
9813    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9814    use askama::{Result, Values};
9815
9816    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9817    ///
9818    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9819    /// (dashes, "No prior scan", etc.) passes through untouched.
9820    #[askama::filter_fn]
9821    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9822        Ok(super::group_thousands(&value.to_string()))
9823    }
9824}
9825
9826#[derive(Deserialize, Default)]
9827struct MultiCompareQuery {
9828    runs: Option<String>,
9829    /// "super" to show only super-repo files (exclude all submodule files)
9830    scope: Option<String>,
9831    /// Submodule name to narrow the comparison to one submodule
9832    sub: Option<String>,
9833}
9834
9835#[allow(clippy::too_many_lines)]
9836async fn multi_compare_handler(
9837    State(state): State<AppState>,
9838    Query(params): Query<MultiCompareQuery>,
9839    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9840) -> impl IntoResponse {
9841    let run_ids: Vec<String> = params
9842        .runs
9843        .as_deref()
9844        .unwrap_or("")
9845        .split(',')
9846        .map(|s| s.trim().to_string())
9847        .filter(|s| !s.is_empty())
9848        .collect();
9849
9850    if run_ids.len() < 2 {
9851        return Html(
9852            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9853             <a href=\"/compare-scans\">Go back</a></p>",
9854        )
9855        .into_response();
9856    }
9857    if run_ids.len() > 20 {
9858        return Html(
9859            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9860             at once. <a href=\"/compare-scans\">Go back</a></p>",
9861        )
9862        .into_response();
9863    }
9864
9865    // Look up each run_id in the registry.
9866    let entries: Vec<Option<RegistryEntry>> = {
9867        let reg = state.registry.lock().await;
9868        run_ids
9869            .iter()
9870            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9871            .collect()
9872    };
9873
9874    for (i, entry) in entries.iter().enumerate() {
9875        if entry.is_none() {
9876            let html = format!(
9877                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9878                 found. <a href=\"/compare-scans\">Go back</a></p>",
9879                run_ids[i]
9880            );
9881            return Html(html).into_response();
9882        }
9883    }
9884
9885    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9886
9887    for entry in &entries {
9888        if entry.json_path.is_none() {
9889            let html = format!(
9890                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9891                 JSON data — re-run the analysis to enable comparison. \
9892                 <a href=\"/compare-scans\">Go back</a></p>",
9893                entry.run_id
9894            );
9895            return Html(html).into_response();
9896        }
9897    }
9898
9899    // Sort chronologically.
9900    entries.sort_by_key(|e| e.timestamp_utc);
9901
9902    // Load JSON for each entry.
9903    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9904    for entry in &entries {
9905        let path = entry.json_path.as_ref().unwrap();
9906        match read_json(path) {
9907            Ok(r) => runs.push(r),
9908            Err(e) => {
9909                let html = format!(
9910                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9911                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9912                    entry.run_id
9913                );
9914                return Html(html).into_response();
9915            }
9916        }
9917    }
9918
9919    // Collect submodule names from all runs.
9920    let all_sub_names: Vec<String> = {
9921        let mut set = std::collections::BTreeSet::new();
9922        for r in &runs {
9923            for s in &r.submodule_summaries {
9924                set.insert(s.name.clone());
9925            }
9926        }
9927        set.into_iter().collect()
9928    };
9929    let has_submodule_data = !all_sub_names.is_empty();
9930    let active_submodule = params.sub.clone();
9931    let super_scope_active = params.scope.as_deref() == Some("super");
9932
9933    // Narrow per_file_records when a scope is active, then recompute totals.
9934    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9935
9936    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9937    let project_label = entries
9938        .first()
9939        .map_or("", |e| e.project_label.as_str())
9940        .to_string();
9941    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9942    let multi = compute_multi_delta(&run_refs);
9943    let html = multi_compare_page(
9944        &multi,
9945        &project_label,
9946        env!("CARGO_PKG_VERSION"),
9947        &csp_nonce,
9948        has_submodule_data,
9949        &all_sub_names,
9950        &runs_csv,
9951        super_scope_active,
9952        active_submodule.as_deref(),
9953        &entries,
9954    );
9955    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9956    // copy after a rebuild would silently mask UI fixes.
9957    (
9958        [(axum::http::header::CACHE_CONTROL, "no-store")],
9959        Html(html),
9960    )
9961        .into_response()
9962}
9963
9964const fn multi_delta_class(n: i64) -> &'static str {
9965    match n {
9966        1.. => "pos",
9967        ..=-1 => "neg",
9968        0 => "zero",
9969    }
9970}
9971
9972fn multi_fmt_delta(n: i64) -> String {
9973    if n > 0 {
9974        format!("+{n}")
9975    } else {
9976        format!("{n}")
9977    }
9978}
9979
9980/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9981fn js_escape(s: &str) -> String {
9982    use std::fmt::Write as _;
9983    let mut out = String::with_capacity(s.len() + 2);
9984    for c in s.chars() {
9985        match c {
9986            '"' => out.push_str("\\\""),
9987            '\\' => out.push_str("\\\\"),
9988            '\n' => out.push_str("\\n"),
9989            '\r' => out.push_str("\\r"),
9990            '\t' => out.push_str("\\t"),
9991            c if (c as u32) < 0x20 => {
9992                let _ = write!(out, "\\u{:04x}", c as u32);
9993            }
9994            c => out.push(c),
9995        }
9996    }
9997    out
9998}
9999
10000/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
10001fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
10002    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
10003        return (
10004            "&mdash;".to_string(),
10005            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
10006        );
10007    };
10008    let cd = entry
10009        .git_commit_date
10010        .as_deref()
10011        .and_then(fmt_git_date)
10012        .unwrap_or_else(|| "&mdash;".to_string());
10013    let au = entry.git_author.as_deref().map_or_else(
10014        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
10015        |a| {
10016            format!(
10017                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
10018                 <span class=\"cmp-author-handle\"></span></span>",
10019                html_escape(a)
10020            )
10021        },
10022    );
10023    (cd, au)
10024}
10025
10026/// Render the scope badge chip for a scan card header.
10027fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
10028    active_sub.map_or_else(
10029        || {
10030            if super_scope_active {
10031                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
10032            } else {
10033                "<span class=\"mc-scope-tag mc-scope-full\">\
10034                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
10035                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
10036                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
10037                 <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>\
10038                 </svg> Full scan</span>"
10039                    .to_string()
10040            }
10041        },
10042        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
10043    )
10044}
10045
10046/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
10047fn build_mc_scan_strip(
10048    multi: &MultiScanComparison,
10049    entries: &[RegistryEntry],
10050    n: usize,
10051    is_many: bool,
10052    active_sub: Option<&str>,
10053    super_scope_active: bool,
10054    project_label: &str,
10055) -> String {
10056    use std::fmt::Write as _;
10057    let mut scan_strip = String::new();
10058    for (i, pt) in multi.points.iter().enumerate() {
10059        let ts_ms = pt.timestamp.timestamp_millis();
10060        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10061        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
10062        let branch = pt.git_branch.as_deref().unwrap_or("");
10063        let report_link = format!("/runs/html/{}", pt.run_id);
10064        let branch_html = if branch.is_empty() {
10065            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
10066        } else {
10067            format!(
10068                "<span class=\"mc-card-branch\">{}</span>",
10069                html_escape(branch)
10070            )
10071        };
10072        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
10073        let tags_html = pt
10074            .git_tags
10075            .as_deref()
10076            .filter(|t| !t.is_empty())
10077            .map(|t| {
10078                let chips = t
10079                    .split(',')
10080                    .filter(|s| !s.is_empty())
10081                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
10082                    .collect::<Vec<_>>()
10083                    .join(" ");
10084                format!(
10085                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
10086                     <span class=\"mc-row-val\">{chips}</span></div>"
10087                )
10088            })
10089            .unwrap_or_default();
10090        let nearest = pt
10091            .git_nearest_tag
10092            .as_deref()
10093            .map(|t| format!("near {}", html_escape(t)))
10094            .unwrap_or_default();
10095        let arrow = if i < n - 1 && !is_many {
10096            "<div class='mc-arrow'>&#8594;</div>"
10097        } else {
10098            ""
10099        };
10100        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
10101        let nearest_html = if nearest.is_empty() {
10102            String::new()
10103        } else {
10104            format!(
10105                "<span class=\"mc-card-nearest-wrap\">\
10106                 <span class=\"mc-card-nearest\">{nearest}</span>\
10107                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
10108                 </span>"
10109            )
10110        };
10111        write!(
10112            scan_strip,
10113            r#"<div class="mc-card">
10114              <div class="mc-card-header">
10115                <div class="mc-card-num">Scan {num}</div>
10116                <div class="mc-card-project-col">
10117                  <div class="mc-card-project">{project_label}</div>
10118                  {scope_badge}
10119                </div>
10120              </div>
10121              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
10122              <div class="mc-card-rows">
10123                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
10124                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
10125                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
10126                <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>
10127                {tags_html}
10128              </div>
10129              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
10130            </div>{arrow}"#,
10131            num = i + 1,
10132            commit = html_escape(commit),
10133            commit_date = commit_date_html,
10134            ts_ms = ts_ms,
10135            code = fmt_num(pt.code_lines),
10136            scope_badge = scope_badge,
10137            nearest_html = nearest_html,
10138        )
10139        .unwrap();
10140    }
10141    scan_strip
10142}
10143
10144/// Build the metric progression table (thead + tbody) for multi-compare.
10145#[allow(clippy::too_many_lines)]
10146fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
10147    use std::fmt::Write as _;
10148    struct MetricRow<'a> {
10149        label: &'a str,
10150        values: Vec<i64>,
10151        seq_deltas: Vec<i64>,
10152        net_delta: i64,
10153    }
10154    let rows: Vec<MetricRow<'_>> = vec![
10155        MetricRow {
10156            label: "Code Lines",
10157            values: multi.points.iter().map(|p| p.code_lines).collect(),
10158            seq_deltas: multi
10159                .sequential_deltas
10160                .iter()
10161                .map(|d| d.summary.code_lines_delta)
10162                .collect(),
10163            net_delta: multi.total_delta.code_lines_delta,
10164        },
10165        MetricRow {
10166            label: "Files Analyzed",
10167            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
10168            seq_deltas: multi
10169                .sequential_deltas
10170                .iter()
10171                .map(|d| d.summary.files_analyzed_delta)
10172                .collect(),
10173            net_delta: multi.total_delta.files_analyzed_delta,
10174        },
10175        MetricRow {
10176            label: "Comment Lines",
10177            values: multi.points.iter().map(|p| p.comment_lines).collect(),
10178            seq_deltas: multi
10179                .sequential_deltas
10180                .iter()
10181                .map(|d| d.summary.comment_lines_delta)
10182                .collect(),
10183            net_delta: multi.total_delta.comment_lines_delta,
10184        },
10185        MetricRow {
10186            label: "Blank Lines",
10187            values: multi.points.iter().map(|p| p.blank_lines).collect(),
10188            seq_deltas: multi
10189                .sequential_deltas
10190                .iter()
10191                .map(|d| d.summary.blank_lines_delta)
10192                .collect(),
10193            net_delta: multi.total_delta.blank_lines_delta,
10194        },
10195        MetricRow {
10196            label: "Tests",
10197            values: multi.points.iter().map(|p| p.test_count).collect(),
10198            seq_deltas: multi
10199                .points
10200                .windows(2)
10201                .map(|pts| pts[1].test_count - pts[0].test_count)
10202                .collect(),
10203            net_delta: multi.points.last().map_or(0, |l| l.test_count)
10204                - multi.points.first().map_or(0, |f| f.test_count),
10205        },
10206    ];
10207    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
10208    for i in 0..n {
10209        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
10210        if i < n - 1 {
10211            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
10212        }
10213    }
10214    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
10215    let mut metrics_tbody = String::new();
10216    for row in &rows {
10217        metrics_tbody.push_str("<tr>");
10218        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
10219        for i in 0..n {
10220            write!(
10221                metrics_tbody,
10222                "<td class='mc-val-col'>{}</td>",
10223                fmt_comma(row.values[i])
10224            )
10225            .unwrap();
10226            if i < n - 1 {
10227                let d = row.seq_deltas[i];
10228                write!(
10229                    metrics_tbody,
10230                    "<td class='mc-delta-col {cls}'>{val}</td>",
10231                    cls = multi_delta_class(d),
10232                    val = multi_fmt_delta(d)
10233                )
10234                .unwrap();
10235            }
10236        }
10237        let nd = row.net_delta;
10238        write!(
10239            metrics_tbody,
10240            "<td class='mc-net-col {cls}'>{val}</td>",
10241            cls = multi_delta_class(nd),
10242            val = multi_fmt_delta(nd)
10243        )
10244        .unwrap();
10245        metrics_tbody.push_str("</tr>");
10246    }
10247    (metrics_thead, metrics_tbody)
10248}
10249
10250/// Build the JS-embeddable points JSON array for the multi-compare chart.
10251fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10252    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10253    for (i, pt) in multi.points.iter().enumerate() {
10254        let commit = pt.git_commit.as_deref().unwrap_or("");
10255        let branch = pt.git_branch.as_deref().unwrap_or("");
10256        let tags = pt.git_tags.as_deref().unwrap_or("");
10257        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10258        let scanned_ms = pt.timestamp.timestamp_millis();
10259        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10260        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10261        let commit_date = entry
10262            .and_then(|e| e.git_commit_date.as_deref())
10263            .and_then(fmt_git_date)
10264            .unwrap_or_default();
10265        let author = entry
10266            .and_then(|e| e.git_author.as_deref())
10267            .unwrap_or("")
10268            .to_string();
10269        let cov = pt
10270            .coverage_line_pct
10271            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10272        parts.push(format!(
10273            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}}}"#,
10274            run_id = js_escape(&pt.run_id),
10275            commit = js_escape(commit),
10276            branch = js_escape(branch),
10277            tags = js_escape(tags),
10278            nearest = js_escape(nearest),
10279            commit_date = js_escape(&commit_date),
10280            author = js_escape(&author),
10281            scanned = js_escape(&scanned),
10282            code = pt.code_lines,
10283            comments = pt.comment_lines,
10284            blank = pt.blank_lines,
10285            files = pt.files_analyzed,
10286            tests = pt.test_count,
10287        ));
10288    }
10289    format!("[{}]", parts.join(","))
10290}
10291
10292/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
10293fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10294    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10295    for row in &multi.file_matrix {
10296        let lang = row.language.as_deref().unwrap_or("");
10297        let codes: Vec<String> = row
10298            .code_per_scan
10299            .iter()
10300            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10301            .collect();
10302        let deltas: Vec<String> = row
10303            .code_delta_per_scan
10304            .iter()
10305            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10306            .collect();
10307        parts.push(format!(
10308            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10309            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10310            status = row.overall_status,
10311            codes = codes.join(","),
10312            deltas = deltas.join(","),
10313            total = row.total_code_delta,
10314        ));
10315    }
10316    format!("[{}]", parts.join(","))
10317}
10318
10319/// Build the column header cells for the file-matrix table.
10320fn build_mc_file_col_headers(n: usize) -> String {
10321    use std::fmt::Write as _;
10322    let mut out = String::new();
10323    for i in 0..n {
10324        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10325        if i < n - 1 {
10326            write!(
10327                out,
10328                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
10329                i + 2
10330            )
10331            .unwrap();
10332        }
10333    }
10334    out
10335}
10336
10337/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
10338fn build_mc_scope_bar(
10339    has_submodule_data: bool,
10340    sub_names: &[String],
10341    runs_csv: &str,
10342    active_sub: Option<&str>,
10343    super_scope_active: bool,
10344) -> String {
10345    use std::fmt::Write as _;
10346    if !has_submodule_data {
10347        return String::new();
10348    }
10349    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10350    let full_active = active_sub.is_none() && !super_scope_active;
10351    let mut bar = format!(
10352        r#"<div class="submod-scope-bar">
10353  <span class="submod-scope-label">
10354    <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>
10355    Scope:
10356  </span>
10357  <div class="submod-scope-divider"></div>
10358  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10359  <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>"#,
10360        full_cls = if full_active { " active" } else { "" },
10361        super_cls = if super_scope_active { " active" } else { "" },
10362    );
10363    for s in sub_names {
10364        let is_active = active_sub == Some(s.as_str());
10365        write!(
10366            bar,
10367            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10368            cls = if is_active { " active" } else { "" },
10369            name_enc = html_escape(s),
10370            name_esc = html_escape(s),
10371        )
10372        .unwrap();
10373    }
10374    bar.push_str("\n</div>");
10375    bar
10376}
10377
10378/// Build the scope-description label shown in the page subtitle.
10379fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10380    active_sub.map_or_else(
10381        || {
10382            if super_scope_active {
10383                "Super-repo only &mdash; ".to_string()
10384            } else {
10385                String::new()
10386            }
10387        },
10388        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
10389    )
10390}
10391
10392#[allow(clippy::too_many_lines)]
10393#[allow(clippy::too_many_arguments)]
10394fn multi_compare_page(
10395    multi: &MultiScanComparison,
10396    project_label: &str,
10397    version: &str,
10398    csp_nonce: &str,
10399    has_submodule_data: bool,
10400    sub_names: &[String],
10401    runs_csv: &str,
10402    super_scope_active: bool,
10403    active_sub: Option<&str>,
10404    entries: &[RegistryEntry],
10405) -> String {
10406    let n = multi.points.len();
10407    let is_many = n > 4;
10408    let mc_strip_class = if is_many {
10409        "mc-strip mc-strip-grid"
10410    } else {
10411        "mc-strip"
10412    };
10413
10414    // ── Scan strip cards ──────────────────────────────────────────────────────
10415    let scan_strip = build_mc_scan_strip(
10416        multi,
10417        entries,
10418        n,
10419        is_many,
10420        active_sub,
10421        super_scope_active,
10422        project_label,
10423    );
10424
10425    // ── Summary metrics table ─────────────────────────────────────────────────
10426    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10427
10428    // ── Chart data and table helpers ──────────────────────────────────────────
10429    let points_json = build_mc_points_json(multi, entries);
10430    let file_matrix_json = build_mc_file_matrix_json(multi);
10431
10432    // Counts for filter tabs
10433    let files_modified = multi
10434        .file_matrix
10435        .iter()
10436        .filter(|f| f.overall_status == "modified")
10437        .count();
10438    let files_added = multi
10439        .file_matrix
10440        .iter()
10441        .filter(|f| f.overall_status == "added")
10442        .count();
10443    let files_removed = multi
10444        .file_matrix
10445        .iter()
10446        .filter(|f| f.overall_status == "removed")
10447        .count();
10448    let files_unchanged = multi
10449        .file_matrix
10450        .iter()
10451        .filter(|f| f.overall_status == "unchanged")
10452        .count();
10453    let total_files = multi.file_matrix.len();
10454
10455    let file_col_headers = build_mc_file_col_headers(n);
10456    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10457    let scope_bar_html = build_mc_scope_bar(
10458        has_submodule_data,
10459        sub_names,
10460        runs_csv,
10461        active_sub,
10462        super_scope_active,
10463    );
10464    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10465    let toast_assets = sloc_toast_assets(csp_nonce);
10466
10467    format!(
10468        r#"<!doctype html>
10469<html lang="en">
10470<head>
10471  <meta charset="utf-8">
10472  <meta name="viewport" content="width=device-width, initial-scale=1">
10473  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10474  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10475  <style nonce="{csp_nonce}">
10476    :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;}}
10477    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10478    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10479    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;}}
10480    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10481    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10482    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10483    .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;}}
10484    @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));}}}}
10485    .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);}}
10486    .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;}}
10487    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10488    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10489    @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;}}}}
10490    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10491    .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));}}
10492    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10493    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10494    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10495    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10496    .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;}}
10497    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10498    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10499    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10500    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10501    .nav-dropdown{{position:relative;display:inline-flex;}}
10502    .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;}}
10503    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10504    .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;}}
10505    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10506    .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);}}
10507    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10508    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10509    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10510    body:not(.dark-theme) .icon-sun{{display:none;}}
10511    body.dark-theme .icon-moon{{display:none;}}
10512    .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;}}
10513    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10514    .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);}}
10515    .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;}}
10516    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10517    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10518    .settings-modal-body{{padding:14px 16px 16px;}}
10519    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10520    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10521    .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;}}
10522    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10523    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10524    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10525    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10526    .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;}}
10527    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10528    .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;}}
10529    .btn-back:hover{{background:var(--line);}}
10530    .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;}}
10531    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;}}
10532    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10533    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10534    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10535    .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;}}
10536    .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;}}
10537    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10538    .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;}}
10539    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10540    body.dark-theme .mc-card{{background:var(--surface-2);}}
10541    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10542    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10543    .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%;}}
10544    .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;}}
10545    .mc-card-commit:hover{{color:var(--oxide);}}
10546    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10547    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10548    .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;}}
10549    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10550    .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;}}
10551    .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;}}
10552    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10553    .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;}}
10554    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10555    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10556    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10557    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10558    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10559    .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);}}
10560    .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);}}
10561    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10562    .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;}}
10563    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10564    .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;}}
10565    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10566    .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;}}
10567    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10568    .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;}}
10569    .submod-scope-btn:hover{{background:var(--line);}}
10570    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10571    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10572    .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;}}
10573    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10574    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10575    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10576    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10577    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10578    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10579    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10580    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10581    .metrics-table .pos{{color:var(--pos);}}
10582    .metrics-table .neg{{color:var(--neg);}}
10583    .metrics-table .zero{{color:var(--muted);}}
10584    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10585    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10586    .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;}}
10587    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10588    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10589    .chart-wrap{{width:100%;overflow-x:auto;}}
10590    #mc-chart{{display:block;width:100%;}}
10591    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10592    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10593    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10594    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10595    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10596    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10597    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10598    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10599    .ic-card-h2-row .ic-card-h2{{margin:0;}}
10600    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10601    .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;}}
10602    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10603    .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;}}
10604    .ic-svg-modal-ov.open{{display:flex;}}
10605    .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);}}
10606    .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);}}
10607    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10608    .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;}}
10609    .ic-svg-modal-close:hover{{background:var(--line);}}
10610    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10611    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10612    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10613    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10614    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10615    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10616    #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;}}
10617    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10618    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10619    .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;}}
10620    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10621    .tab-btn:hover:not(.active){{background:var(--line);}}
10622    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10623    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10624    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10625    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10626    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10627    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10628    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10629    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10630    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10631    .table-wrap{{width:100%;overflow-x:auto;}}
10632    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10633    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10634    #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;}}
10635    #file-table th.left,#file-table td.left{{text-align:left;}}
10636    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10637    .file-delta-col{{color:var(--muted);font-size:11px;}}
10638    .file-net-col{{font-weight:800;}}
10639    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10640    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10641    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10642    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10643    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10644    .status-badge.modified{{background:#fff2d8;color:#926000;}}
10645    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10646    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10647    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10648    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10649    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10650    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10651    tr.row-added td{{background:rgba(26,143,71,0.04);}}
10652    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10653    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10654    tr.row-unchanged td{{color:var(--muted);}}
10655    tr.row-unchanged .status-badge{{opacity:.65;}}
10656    .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;}}
10657    .absent{{color:var(--muted);font-style:italic;}}
10658    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10659    .pagination-info{{font-size:12px;color:var(--muted);}}
10660    .pagination-btns{{display:flex;gap:5px;}}
10661    .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;}}
10662    .pg-btn:hover:not(:disabled){{background:var(--line);}}
10663    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10664    .pg-btn:disabled{{opacity:.35;cursor:default;}}
10665    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;}}
10666    .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;}}
10667    .export-btn:hover{{background:var(--line);}}
10668    .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;}}
10669    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10670    .site-footer a{{color:var(--muted);}}
10671    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;}}
10672    body.pdf-mode{{background:#fff!important;}}
10673    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10674    .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;}}
10675    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10676    .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;}}
10677    .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;}}
10678    .mc-modal-title{{font-size:18px;font-weight:800;}}
10679    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10680    .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;}}
10681    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10682    .mc-modal-body{{padding:18px 22px;}}
10683    .mc-modal-sec{{margin-bottom:20px;}}
10684    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10685    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10686    .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;}}
10687    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10688    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10689    .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;}}
10690    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10691    .mc-modal-row:last-child{{border-bottom:none;}}
10692    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10693    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10694    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10695    .mc-modal-val a:hover{{text-decoration:underline;}}
10696    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10697    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10698    .mc-modal-stat[data-tip]{{cursor:help;}}
10699    #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);}}
10700    .mc-card{{cursor:pointer;}}
10701    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10702  </style>
10703</head>
10704<body>
10705  {loading_overlay}
10706  <div class="background-watermarks" aria-hidden="true">
10707    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10708    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10709    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10710    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10711    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10712    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10713  </div>
10714  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10715  <div class="top-nav">
10716    <div class="top-nav-inner">
10717      <a class="brand" href="/">
10718        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10719        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10720      </a>
10721      <div class="nav-right">
10722        <a class="nav-pill" href="/">Home</a>
10723        <div class="nav-dropdown">
10724          <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>
10725          <div class="nav-dropdown-menu">
10726            <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>
10727          </div>
10728        </div>
10729        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10730        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10731        <div class="nav-dropdown">
10732          <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>
10733          <div class="nav-dropdown-menu">
10734            <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>
10735          </div>
10736        </div>
10737        <div class="server-status-wrap" id="server-status-wrap">
10738          <div class="nav-pill server-online-pill" id="server-status-pill">
10739            <span class="status-dot" id="status-dot"></span>
10740            <span id="server-status-label">Server</span>
10741            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10742          </div>
10743          <div class="server-status-tip">
10744            OxideSLOC is running &mdash; accessible on your network.
10745            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10746          </div>
10747        </div>
10748        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10749          <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>
10750        </button>
10751        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10752          <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>
10753          <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>
10754        </button>
10755      </div>
10756    </div>
10757  </div>
10758
10759  <div class="page">
10760    <!-- Hero header -->
10761    <div class="mc-hero">
10762      <div class="mc-hero-header">
10763        <div>
10764          <div class="mc-title">Multi-Scan Timeline</div>
10765          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10766          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10767        </div>
10768        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10769          <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>
10770          <div class="export-group" id="mc-top-export-group">
10771            <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>
10772            <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>
10773          </div>
10774        </div>
10775      </div>
10776      {scope_bar_html}
10777      <!-- Scan strip -->
10778      <div class="{mc_strip_class}">{scan_strip}</div>
10779    </div>
10780
10781    <!-- Summary metrics table -->
10782    <div class="panel">
10783      <div class="panel-title">Metric Progression</div>
10784      <div class="table-wrap">
10785        <table class="metrics-table">
10786          <thead>{metrics_thead}</thead>
10787          <tbody>{metrics_tbody}</tbody>
10788        </table>
10789      </div>
10790    </div>
10791
10792    <!-- Scan Charts -->
10793    <div class="panel" id="mc-charts-panel">
10794      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10795      <div class="ic-grid">
10796        <!-- Timeline line chart — spans full width -->
10797        <div class="ic-card" style="grid-column:span 2">
10798          <div class="ic-card-h2-row">
10799            <span class="ic-card-h2">Timeline</span>
10800            <div class="chart-toolbar" style="margin:0">
10801              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10802              <button class="chart-metric-btn" data-metric="files">Files</button>
10803              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10804              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10805              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10806            </div>
10807          </div>
10808          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10809        </div>
10810        <!-- Code Metrics: Scan 1 vs Latest -->
10811        <div class="ic-card">
10812          <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>
10813          <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>
10814          <div id="mc-ic-c1"></div>
10815        </div>
10816        <!-- Language Code Delta -->
10817        <div class="ic-card" id="mc-ic-lang-card">
10818          <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>
10819          <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>
10820          <div id="mc-ic-c3"></div>
10821        </div>
10822        <!-- Delta by Metric -->
10823        <div class="ic-card">
10824          <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>
10825          <div id="mc-ic-c2"></div>
10826        </div>
10827        <!-- File Change Distribution -->
10828        <div class="ic-card">
10829          <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>
10830          <div id="mc-ic-c4"></div>
10831        </div>
10832      </div>
10833    </div>
10834
10835    <!-- File matrix table -->
10836    <div class="panel">
10837      <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>
10838      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10839        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10840          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10841          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10842          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10843          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10844          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10845        </div>
10846        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10847          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10848          <div class="export-group">
10849          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10850          <button type="button" class="export-btn" id="export-csv-btn">
10851            <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>
10852            CSV
10853          </button>
10854          <button type="button" class="export-btn" id="mc-file-xls-btn">
10855            <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>
10856            Excel
10857          </button>
10858          </div>
10859        </div>
10860      </div>
10861      <div class="table-wrap">
10862        <table id="file-table">
10863          <thead>
10864            <tr>
10865              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10866              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10867              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10868              {file_col_headers}
10869              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10870            </tr>
10871          </thead>
10872          <tbody id="file-tbody"></tbody>
10873        </table>
10874      </div>
10875      <div class="pagination">
10876        <span class="pagination-info" id="pg-info"></span>
10877        <div class="pagination-btns" id="pg-btns"></div>
10878        <div style="display:flex;align-items:center;gap:6px;">
10879          <span style="font-size:12px;color:var(--muted)">Show</span>
10880          <select class="per-page" id="per-page-sel">
10881            <option value="25" selected>25 per page</option>
10882            <option value="50">50 per page</option>
10883            <option value="100">100 per page</option>
10884          </select>
10885        </div>
10886      </div>
10887    </div>
10888  </div>
10889
10890  <div id="mc-ic-tt"></div>
10891
10892  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10893    <div class="ic-svg-modal">
10894      <div class="ic-svg-modal-hdr">
10895        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10896        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10897      </div>
10898      <div id="ic-svg-modal-body"></div>
10899    </div>
10900  </div>
10901
10902  <footer class="site-footer">
10903    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10904    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10905    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10906    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10907    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10908  </footer>
10909
10910  <script nonce="{csp_nonce}">
10911  (function(){{
10912    // ── Dark theme ───────────────────────────────────────────────────────────
10913    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10914    var renderInlineCharts=null;
10915    var tt=document.getElementById('theme-toggle');
10916    if(tt)tt.addEventListener('click',function(){{
10917      var on=document.body.classList.toggle('dark-theme');
10918      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10919      renderChart(activeMetric);
10920      if(renderInlineCharts)renderInlineCharts();
10921    }});
10922
10923    // ── Code particles ───────────────────────────────────────────────────────
10924    var container=document.getElementById('code-particles');
10925    if(container){{
10926      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()'];
10927      for(var i=0;i<28;i++){{
10928        (function(idx){{
10929          var el=document.createElement('span');el.className='code-particle';
10930          el.textContent=snips[idx%snips.length];
10931          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10932          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10933          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10934          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10935          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10936          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10937          container.appendChild(el);
10938        }})(i);
10939      }}
10940    }}
10941
10942    // ── Watermarks ───────────────────────────────────────────────────────────
10943    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10944    if(wms.length){{
10945      var placed=[];
10946      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;}}
10947      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];}}
10948      var half=Math.floor(wms.length/2);
10949      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;}});
10950    }}
10951
10952    // ── Settings / colour scheme modal ───────────────────────────────────────
10953    (function(){{
10954      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'}}];
10955      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);}});}}
10956      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10957      function init(){{
10958        var btn=document.getElementById('settings-btn');if(!btn)return;
10959        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10960        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>';
10961        document.body.appendChild(m);
10962        var g=document.getElementById('scheme-grid');
10963        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);}});
10964        var cl=document.getElementById('settings-close-btn');
10965        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');}});
10966        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10967        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10968      }}
10969      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10970    }})();
10971
10972    // ── Timezone support for scan timestamps ─────────────────────────────────
10973    (function(){{
10974      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);}};
10975      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'';}}}};
10976      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);}});}};
10977      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10978      window.applyTz(storedTz);
10979      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);}});}}}}
10980      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10981    }})();
10982
10983    // ── Data ────────────────────────────────────────────────────────────────
10984    var POINTS={points_json};
10985    var FILES={file_matrix_json};
10986    var N={n};
10987
10988    // ── fmt helper ───────────────────────────────────────────────────────────
10989    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();}}
10990    function fmtFull(n){{return Number(n).toLocaleString();}}
10991    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10992
10993    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10994    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10995    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));}}
10996    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10997    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10998
10999    // ── Timeline chart ───────────────────────────────────────────────────────
11000    var activeMetric='code';
11001    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
11002    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
11003
11004    function renderChart(metric){{
11005      var svg=document.getElementById('mc-chart');if(!svg)return;
11006      var W=svg.getBoundingClientRect().width||800,H=280;
11007      svg.setAttribute('height',H);
11008      var pad={{l:62,r:20,t:32,b:72}};
11009      var dark=document.body.classList.contains('dark-theme');
11010      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
11011      var valid=pts.filter(function(v){{return v!=null;}});
11012      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;}}
11013      var minV=0,maxV=Math.max.apply(null,valid);
11014      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
11015      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
11016      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
11017      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
11018      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
11019      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
11020      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
11021      var parts=[];
11022      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
11023      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>');}}
11024      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
11025      var lineD='';var firstPt=true;
11026      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);}}}}
11027      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
11028      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
11029      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
11030      for(var i=0;i<N;i++){{
11031        if(pts[i]==null)continue;
11032        var cx=xOf(i),cy=yOf(pts[i]);
11033        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
11034        var hasTag=p.tags&&p.tags.length>0;
11035        // Permanent Y-value label above the dot
11036        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>');
11037        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+'"/>');
11038        var xanchor=i===0?'start':i===N-1?'end':'middle';
11039        // X-axis label at 2× the original size (18 px)
11040        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>');
11041      }}
11042      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
11043      svg.setAttribute('viewBox','0 0 '+W+' '+H);
11044      svg.innerHTML=parts.join('');
11045      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');}});
11046      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
11047      svg.onmousemove=function(e){{
11048        var rect=svg.getBoundingClientRect();
11049        var scaleX=W/rect.width;
11050        var mouseX=(e.clientX-rect.left)*scaleX;
11051        var nearest=-1,minDist=Infinity;
11052        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;}}}}
11053        if(nearest<0)return;
11054        var nc=xOf(nearest),ny=yOf(pts[nearest]);
11055        var xhair=svg.querySelector('.mc-xhair');
11056        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
11057        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"/>';
11058        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
11059        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
11060        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>';
11061        var bx=rect.left+(nc/W*rect.width)+18;
11062        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
11063        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
11064      }};
11065      svg.onmouseleave=function(){{
11066        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
11067        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
11068      }};
11069    }}
11070
11071    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11072
11073    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
11074      btn.addEventListener('click',function(){{
11075        activeMetric=this.dataset.metric;
11076        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
11077        this.classList.add('active');
11078        renderChart(activeMetric);
11079      }});
11080    }});
11081    if(typeof ResizeObserver!=='undefined'){{
11082      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
11083    }}
11084    renderChart(activeMetric);
11085
11086    // ── File matrix table ────────────────────────────────────────────────────
11087    var activeStatus='';
11088    var currentPage=1;
11089    var perPage=25;
11090    var mcSortCol=null,mcSortAsc=true;
11091
11092    function getFiltered(){{
11093      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
11094      if(!mcSortCol)return data;
11095      var asc=mcSortAsc;
11096      return data.slice().sort(function(a,b){{
11097        var va,vb;
11098        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
11099        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
11100        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
11101        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
11102        else{{return 0;}}
11103        if(asc)return va<vb?-1:va>vb?1:0;
11104        return va<vb?1:va>vb?-1:0;
11105      }});
11106    }}
11107
11108    function renderFilePage(){{
11109      var filtered=getFiltered();
11110      var total=filtered.length;
11111      var totalPages=Math.max(1,Math.ceil(total/perPage));
11112      if(currentPage>totalPages)currentPage=totalPages;
11113      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
11114      var tbody=document.getElementById('file-tbody');if(!tbody)return;
11115      var rows=[];
11116      for(var i=start;i<end;i++){{
11117        var f=filtered[i];
11118        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
11119        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
11120        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
11121        for(var j=0;j<N;j++){{
11122          var cv=f.c[j];
11123          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
11124          if(j<N-1){{
11125            var dv=f.d[j+1];
11126            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
11127              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
11128          }}
11129        }}
11130        var tc=f.t;
11131        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
11132        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
11133      }}
11134      tbody.innerHTML=rows.join('');
11135
11136      var info=document.getElementById('pg-info');
11137      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
11138      renderPgBtns(totalPages);
11139    }}
11140
11141    function renderPgBtns(totalPages){{
11142      var wrap=document.getElementById('pg-btns');if(!wrap)return;
11143      var btns=[];
11144      function mkBtn(label,page,active,disabled){{
11145        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
11146        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
11147      }}
11148      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
11149      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
11150      if(s>1)btns.push(mkBtn('1',1,false,false));
11151      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
11152      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
11153      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
11154      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
11155      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
11156      wrap.innerHTML=btns.join('');
11157      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
11158        b.addEventListener('click',function(){{
11159          var pg=parseInt(this.dataset.pg,10);
11160          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
11161        }});
11162      }});
11163    }}
11164
11165    // Tab filter
11166    document.querySelectorAll('.tab-btn').forEach(function(btn){{
11167      btn.addEventListener('click',function(){{
11168        activeStatus=this.dataset.status||'';
11169        currentPage=1;
11170        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11171        this.classList.add('active');
11172        renderFilePage();
11173      }});
11174    }});
11175
11176    // Per-page selector
11177    var ppSel=document.getElementById('per-page-sel');
11178    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
11179
11180    // ── Column header sort ───────────────────────────────────────────────────
11181    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
11182      th.addEventListener('click',function(){{
11183        var col=th.dataset.sortCol;
11184        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
11185        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11186          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
11187        }});
11188        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
11189        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
11190        currentPage=1;renderFilePage();
11191      }});
11192    }});
11193
11194    // Reset button also clears sort
11195    var mcResetBtn=document.getElementById('mc-file-reset-btn');
11196    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
11197      mcSortCol=null;mcSortAsc=true;
11198      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
11199        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
11200      }});
11201      activeStatus='';currentPage=1;
11202      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11203      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
11204      renderFilePage();
11205    }});
11206
11207    renderFilePage();
11208
11209    // ── CSV export ───────────────────────────────────────────────────────────
11210    var exportBtn=document.getElementById('export-csv-btn');
11211    if(exportBtn)exportBtn.addEventListener('click',function(){{
11212      var header=['File','Language','Status'];
11213      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
11214      header.push('Net Delta');
11215      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
11216      var filtered=getFiltered();
11217      filtered.forEach(function(f){{
11218        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
11219        for(var j=0;j<N;j++){{
11220          cols.push(f.c[j]!=null?f.c[j]:'');
11221          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
11222        }}
11223        cols.push(f.t);
11224        rows.push(cols.join(','));
11225      }});
11226      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
11227      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11228      a.download=mcExportName('csv');a.click();
11229    }});
11230
11231    // ── File matrix extra export buttons ─────────────────────────────────────
11232    (function(){{
11233      var resetBtn=document.getElementById('mc-file-reset-btn');
11234      if(resetBtn)resetBtn.addEventListener('click',function(){{
11235        activeStatus='';currentPage=1;
11236        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11237        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11238        renderFilePage();
11239      }});
11240
11241      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11242      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11243      function mcMakeXlsx(fname){{
11244        var filtered=getFiltered();
11245        var enc=new TextEncoder();
11246        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;}}
11247        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;}}
11248        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11249        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11250        var ss=[],si={{}};
11251        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11252        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11253        function WS(){{
11254          var R=0,buf=[];
11255          function cl(c){{return String.fromCharCode(65+c);}}
11256          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11257          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11258          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11259          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>';}}
11260          return{{sc:sc,nc:nc,row:row,xml:xml}};
11261        }}
11262        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11263        var proj=mcExportProj();
11264        // \u2500\u2500 Summary sheet \u2500\u2500
11265        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11266        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11267        r1(s1(0,proj,2));
11268        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11269        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
11270        r1('');
11271        r1(s1(0,'SCAN SUMMARY',8));
11272        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));
11273        POINTS.forEach(function(p,i){{
11274          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11275          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));
11276        }});
11277        r1('');
11278        if(POINTS.length>1){{
11279          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11280          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11281          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11282          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)));}};
11283          nr('Code Lines',pf.code,pl.code);
11284          nr('Comment Lines',pf.comments,pl.comments);
11285          nr('Files Analyzed',pf.files,pl.files);
11286          nr('Tests',pf.tests,pl.tests);
11287          r1('');
11288        }}
11289        var cMod=0,cAdd=0,cRem=0,cUnch=0;
11290        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11291        var totF=FILES.length||1;
11292        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11293        r1(s1(0,'FILE CHANGES',8));
11294        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11295        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11296        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11297        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11298        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11299        r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11300        var lm={{}};
11301        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;}});
11302        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11303        if(langs.length){{
11304          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11305          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11306          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)));}});
11307        }}
11308        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11309        // \u2500\u2500 File Delta sheet \u2500\u2500
11310        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11311        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11312        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);}}
11313        hcells+=s2(hc,'Net Delta',3);
11314        r2(hcells);
11315        filtered.forEach(function(f){{
11316          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11317          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));}}}}
11318          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11319          r2(cells);
11320        }});
11321        var ncols=3+N+(N-1)+1;
11322        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11323        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>';
11324        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11325        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>',
11326          '_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>',
11327          '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>',
11328          '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>',
11329          '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>',
11330          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11331        var zparts=[],zcds=[],zoff=0,znf=0;
11332        ['[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){{
11333          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11334          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]);
11335          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);
11336          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));
11337          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11338          zoff+=entry.length;znf++;
11339        }});
11340        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11341        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]);
11342        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11343        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11344        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11345        out.set(new Uint8Array(eocd),pos);
11346        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11347        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11348      }}
11349
11350      var xlsBtn=document.getElementById('mc-file-xls-btn');
11351      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11352
11353      // File matrix HTML export — interactive: sort by column, filter by status
11354      function mcFileBuildHtml(){{
11355        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11356        var hdrs=['File','Language','Status'];
11357        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11358        hdrs.push('Net \u0394');
11359        var SI=2;
11360        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;}});
11361        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11362        var cnt={{all:allRows.length}};
11363        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11364        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11365        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11366          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11367          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11368          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11369          '.sub{{font-size:12px;color:#99aabb;}}'+
11370          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11371          '.wr{{padding:16px 20px;}}'+
11372          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11373          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11374          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11375          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11376          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11377          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11378          'thead tr{{background:#1a2035;}}'+
11379          '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;}}'+
11380          'th:hover{{background:#2a3050;}}'+
11381          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11382          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11383          'tr:nth-child(even) td{{background:#faf7f4;}}'+
11384          'tr:hover td{{background:#f5f0ea;}}'+
11385          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11386          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11387        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11388        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11389          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11390          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11391          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11392          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11393        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11394          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
11395          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
11396          '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>";}}'+
11397          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11398          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11399          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
11400          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11401          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11402          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11403          '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("")'+
11404          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11405          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11406          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11407          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11408          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11409          'sd=(sc===ci)?-sd:1;sc=ci;'+
11410          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11411          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11412          'render();';
11413        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11414          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11415          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
11416          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11417          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11418          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11419          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11420          '<script>'+inlineJs+'<\/script><\/body><\/html>';
11421      }}
11422
11423      var htmlBtn=document.getElementById('mc-file-html-btn');
11424      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11425        var h=mcFileBuildHtml();
11426        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11427        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11428        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11429      }});
11430
11431      var pdfBtn=document.getElementById('mc-file-pdf-btn');
11432      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11433        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11434      }});
11435    }})();
11436
11437    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11438    (function(){{
11439      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11440      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11441      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11442      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11443      function fmt2(n){{return Number(n).toLocaleString();}}
11444      function px(n){{return Math.round(n);}}
11445      var _tt=document.getElementById('mc-ic-tt');
11446      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11447      function addTT(el){{
11448        if(!el)return;
11449        el.addEventListener('mouseover',function(e){{
11450          var t=e.target.closest('[data-ttl]');
11451          if(t&&_tt){{
11452            var ttl=t.getAttribute('data-ttl');
11453            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11454            _tt.style.display='block';mvTT(e);
11455            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11456            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11457          }} else {{
11458            if(_tt)_tt.style.display='none';
11459            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11460          }}
11461        }});
11462        el.addEventListener('mouseleave',function(){{
11463          if(_tt)_tt.style.display='none';
11464          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11465        }});
11466        el.addEventListener('mousemove',function(e){{mvTT(e);}});
11467      }}
11468      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';}}
11469      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11470      function buildCharts(){{
11471        if(N<2)return;
11472        var cs=getComputedStyle(document.body);
11473        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11474        var textCol=cv('--text','#43342d');
11475        var mutedCol=cv('--muted','#7b675b');
11476        var gFill=cv('--muted-2','#a08777');
11477        var LGY=cv('--line','#e6d0bf');
11478        var axisCol=cv('--line-strong','#d8bfad');
11479        var surf2col=cv('--surface-2','#f4ede4');
11480        var surfCol=cv('--surface','#fff8f0');
11481        var p0=POINTS[0],pLast=POINTS[N-1];
11482        var dark=document.body.classList.contains('dark-theme');
11483        var FADE=dark?'#524238':'#e6d0bf';
11484        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11485        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;}}
11486      var c1mets=[
11487        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11488        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11489        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11490      ];
11491      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11492      // Code Metrics chart — grows to fill the height its grid row settled to (the
11493      // Language Code Delta sibling usually drives that), so it never sits short at
11494      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11495      function drawC1(){{
11496        var C1W=620,C1H=200;
11497        var c1host=document.getElementById('mc-ic-c1');
11498        var c1card=c1host?c1host.closest('.ic-card'):null;
11499        if(c1host&&c1card&&c1host.clientWidth>0){{
11500          var avW=c1host.clientWidth;
11501          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11502          var wantH=availPx*C1W/avW;
11503          if(wantH>C1H)C1H=wantH;
11504        }}
11505        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11506        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11507        for(var gi=1;gi<=4;gi++){{
11508          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11509          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11510          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11511        }}
11512        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11513        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11514        c1mets.forEach(function(m,i){{
11515          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11516          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11517          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11518          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;"/>';
11519          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>';
11520          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;"/>';
11521          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>';
11522          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>';
11523          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>';
11524        }});
11525        c1+='</svg>';
11526        return c1;
11527      }}
11528      // Chart 2: Delta by Metric (net delta first scan to last)
11529      var mets=[
11530        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11531        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11532        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11533      ];
11534      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11535      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;
11536      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11537      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11538      mets.forEach(function(m,i){{
11539        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);
11540        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>';
11541        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;"/>';
11542        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>';}}
11543        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>';}}
11544      }});
11545      c2+='</svg>';
11546      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11547      var lm={{}};
11548      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;}});
11549      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11550      function drawC3(){{
11551        if(!langs.length)return'';
11552        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11553        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;
11554        var c3host=document.getElementById('mc-ic-c3');
11555        var c3card=document.getElementById('mc-ic-lang-card');
11556        var C3H=langs.length*30+24;
11557        if(c3host&&c3card&&c3host.clientWidth>0){{
11558          var avW=c3host.clientWidth;
11559          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11560          var wantH=availPx*C3W/avW;
11561          if(wantH>C3H)C3H=wantH;
11562        }}
11563        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11564        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11565        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11566        langs.forEach(function(l,i){{
11567          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);
11568          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11569          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"/>';
11570          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>';}}
11571          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>';}}
11572          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>';
11573        }});
11574        c3+='</svg>';
11575        return c3;
11576      }}
11577      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11578      var fm=0,fa=0,fr=0,fu=0;
11579      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11580      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;}});
11581      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11582      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11583      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11584      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;
11585      if(segs.length===1){{
11586        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"/>';
11587        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11588        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>';
11589      }} else {{
11590        segs.forEach(function(s){{
11591          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11592          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);
11593          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);
11594          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"/>';
11595          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>';}}
11596          ang4+=sw;
11597        }});
11598      }}
11599      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11600      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11601      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11602      segs.forEach(function(s,i){{
11603        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11604        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;"/>';
11605        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>';
11606        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11607      }});
11608      c4+='</svg>';
11609      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11610      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11611      // once at natural height to seed the row, then both are filled to the row the
11612      // grid settled to, so neither sits short at the top of an over-tall cell.
11613      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11614      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11615      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11616      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11617      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>';
11618      if(e1)e1.innerHTML=drawC1();
11619      }}
11620      buildCharts();
11621      renderInlineCharts=buildCharts;
11622      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11623      (function(){{
11624        var ov=document.getElementById('ic-svg-modal-ov');
11625        var body=document.getElementById('ic-svg-modal-body');
11626        var ttl=document.getElementById('ic-svg-modal-title');
11627        var closeBtn=document.getElementById('ic-svg-modal-close');
11628        if(!ov||!body)return;
11629        function close(){{ov.classList.remove('open');body.innerHTML='';}}
11630        function open(srcId,title){{
11631          var src=document.getElementById(srcId);if(!src)return;
11632          ttl.textContent=title||'';
11633          var card=src.closest('.ic-card');
11634          var legHtml='';
11635          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11636          body.innerHTML=legHtml+src.innerHTML;
11637          var svg=body.querySelector('svg');
11638          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11639          addTT(body);
11640          ov.classList.add('open');
11641        }}
11642        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11643          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11644        }});
11645        if(closeBtn)closeBtn.addEventListener('click',close);
11646        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11647        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11648      }})();
11649
11650      // HTML legend hover → highlight matching SVG bars within the SAME card only
11651      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11652        var metric=leg.getAttribute('data-highlight');
11653        var parentCard=leg.closest('.ic-card');
11654        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11655        if(!chartEl)return;
11656        leg.addEventListener('mouseenter',function(){{
11657          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11658            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11659              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11660              x.style.opacity='1';
11661            }} else {{
11662              x.style.opacity='0.28';
11663            }}
11664          }});
11665        }});
11666        leg.addEventListener('mouseleave',function(){{
11667          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11668        }});
11669      }});
11670      // Author handles
11671      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11672
11673      // ── Export helpers ────────────────────────────────────────────────────────
11674      // Fetch one image from the server and return a data-URI Promise
11675      function mcFetchUri(path){{
11676        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11677          return new Promise(function(res){{
11678            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11679          }});
11680        }}).catch(function(){{return '';}});
11681      }}
11682      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11683      function mcInlineImgs(html,cb){{
11684        var paths=[],seen={{}};
11685        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11686        if(!paths.length){{cb(html);return;}}
11687        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11688          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11689          .catch(function(){{cb(html);}});
11690      }}
11691      // Capture full-page HTML with all table rows visible
11692      function mcRawHtml(pdfMode){{
11693        if(pdfMode)document.body.classList.add('pdf-mode');
11694        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11695        var html=document.documentElement.outerHTML;
11696        perPage=s;currentPage=p;renderFilePage();
11697        if(pdfMode)document.body.classList.remove('pdf-mode');
11698        return html;
11699      }}
11700
11701      // HTML export (full page with inlined images)
11702      function mcDoHtml(btn,fname){{
11703        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11704        mcInlineImgs(mcRawHtml(false),function(html){{
11705          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11706          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11707          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11708          btn.disabled=false;btn.innerHTML=orig;
11709        }});
11710      }}
11711      // PDF export — comprehensive document-style report: full numbers, all sections
11712      function mcBuildPdfHtml(){{
11713        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11714        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11715        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11716        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>';}}
11717        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11718        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11719        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)));}}
11720        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11721        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11722        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11723        // Header/footer flow in document order (NOT position:fixed) — a fixed
11724        // header repeats every printed page in Chromium and overlaps the content
11725        // below it, swallowing the first rows of pages 2+ and clipping the cards
11726        // on page 1. The table <thead> repeats per page natively, so every row
11727        // stays visible.
11728        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11729          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11730          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11731          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11732          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11733          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11734          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11735          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11736          '.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;}}'+
11737          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11738          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11739          '.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;}}'+
11740          '.body{{padding:12px 18px 0;}}'+
11741          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11742          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11743          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11744          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11745          '.sec{{margin-bottom:10px;}}'+
11746          '.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;}}'+
11747          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11748          '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;}}'+
11749          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11750          'tr:nth-child(even) td{{background:#faf8f6;}}';
11751        // ── Metric Progression ────────────────────────────────────────────────
11752        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11753        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11754        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>';
11755        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11756        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11757        var progRows=POINTS.map(function(pt,i){{
11758          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)));
11759          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11760            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11761            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11762            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11763            '<td style="text-align:right">'+full(pt.files)+'</td>';
11764          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11765          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11766          return r+'</tr>';
11767        }}).join('');
11768        // ── Scan-to-scan changes ──────────────────────────────────────────────
11769        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11770          var prev=POINTS[i];
11771          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11772          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11773          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11774            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11775            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11776            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11777            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11778        }}).join(''):'';
11779        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11780        var fmSection='';
11781        if(FILES&&FILES.length){{
11782          // Hard cap on per-scan columns so the table never overflows the page width.
11783          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11784          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11785          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11786          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11787          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11788          var fmRows=topFiles.map(function(f){{
11789            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11790            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>';
11791            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11792            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11793            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>';
11794          }}).join('');
11795          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11796          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11797            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11798        }}
11799        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11800          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11801          '<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>'+
11802
11803          '<div class="body">'+
11804          '<div class="sg">'+
11805          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11806            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11807          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11808            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11809          (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>':
11810            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11811          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11812          '</div>'+
11813          '<div class="sec"><p class="sh">Metric Progression</p>'+
11814          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11815          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11816          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11817          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11818          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11819          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11820          fmSection+
11821          '</div>'+
11822          '<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>'+
11823          '</body></html>';
11824      }}
11825      function mcDoPdf(btn){{
11826        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11827      }}
11828
11829      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11830      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11831      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11832      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11833      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11834      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11835      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11836      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11837      if(location.protocol==='file:'){{
11838        [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';}}}} );
11839        [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';}}}} );
11840      }}
11841    }})();
11842    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11843    (function(){{
11844      function $(id){{return document.getElementById(id);}}
11845      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11846      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11847      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11848      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11849      function openModal(idx){{
11850        var ov=$('mc-modal-overlay');if(!ov)return;
11851        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11852        if(idx<0||idx>=N)return;
11853        var pt=POINTS[idx];
11854        titleEl.textContent='Scan '+(idx+1);
11855        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11856        subEl.textContent=lbl;
11857        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11858          '<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>'+
11859          '<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>'+
11860          '<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>'+
11861          '<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>'+
11862          (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>':'')+
11863          (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>':'')+
11864          '</div></div>';
11865        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11866          (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>':'')+
11867          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11868          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11869          (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>':'')+
11870          (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>':'')+
11871          (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>':'')+
11872          (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>':'')+
11873          '<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>'+
11874          '</div>';
11875        var dHtml='';
11876        if(idx>0){{
11877          var prev=POINTS[idx-1];
11878          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11879          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11880            '<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>'+
11881            '<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>'+
11882            '<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>'+
11883            '</div></div>';
11884        }}
11885        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11886        ov.classList.add('open');document.body.style.overflow='hidden';
11887      }}
11888      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11889      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11890      document.addEventListener('click',function(e){{
11891        if(!e.target||!e.target.closest)return;
11892        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11893        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11894        var card=e.target.closest('.mc-card');
11895        if(!card)return;
11896        if(e.target.closest('a'))return;
11897        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11898        var i=cards.indexOf(card);
11899        if(i>=0)openModal(i);
11900      }});
11901      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11902      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11903      var statTip=null;
11904      document.addEventListener('mousemove',function(e){{
11905        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11906        if(!box){{if(statTip)statTip.style.display='none';return;}}
11907        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11908        var tip=box.getAttribute('data-tip')||'';
11909        if(statTip.textContent!==tip)statTip.textContent=tip;
11910        statTip.style.display='block';
11911        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11912        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11913        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11914        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11915      }});
11916      (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');}})();
11917    }})();
11918  }})();
11919  </script>
11920  <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]';
11921  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;}}
11922  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>
11923  <!-- Scan card detail modal -->
11924  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11925    <div class="mc-modal" id="mc-modal">
11926      <div class="mc-modal-head">
11927        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11928        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11929      </div>
11930      <div class="mc-modal-body" id="mc-modal-body"></div>
11931    </div>
11932  </div>
11933  {toast_assets}
11934</body>
11935</html>"#,
11936        project_label = html_escape(project_label),
11937        n = n,
11938        scan_strip = scan_strip,
11939        mc_strip_class = mc_strip_class,
11940        metrics_thead = metrics_thead,
11941        metrics_tbody = metrics_tbody,
11942        file_col_headers = file_col_headers,
11943        total_files = total_files,
11944        files_modified = files_modified,
11945        files_added = files_added,
11946        files_removed = files_removed,
11947        files_unchanged = files_unchanged,
11948        points_json = points_json,
11949        file_matrix_json = file_matrix_json,
11950        nav_compare_active = nav_compare_active,
11951        version = version,
11952        csp_nonce = csp_nonce,
11953        scope_bar_html = scope_bar_html,
11954        scope_label = scope_label,
11955        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11956    )
11957}
11958
11959// ── Trend report page ─────────────────────────────────────────────────────────
11960// Protected. Interactive time-series chart page that loads scan history via
11961// /api/metrics/history and renders a vanilla-SVG line chart.
11962//
11963// GET /trend-reports
11964
11965#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11966async fn trend_report_handler(
11967    State(state): State<AppState>,
11968    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11969) -> Response {
11970    auto_scan_watched_dirs(&state).await;
11971
11972    let watched_dirs_list: Vec<String> = {
11973        let wd = state.watched_dirs.lock().await;
11974        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11975    };
11976
11977    // Collect distinct project roots for the root selector dropdown.
11978    let roots: Vec<String> = {
11979        let reg = state.registry.lock().await;
11980        let mut seen = std::collections::BTreeSet::new();
11981        reg.entries
11982            .iter()
11983            .flat_map(|e| e.input_roots.iter().cloned())
11984            .filter(|r| seen.insert(r.clone()))
11985            .collect()
11986    };
11987
11988    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11989    let nonce = &csp_nonce;
11990    let version = env!("CARGO_PKG_VERSION");
11991    let toast_assets = sloc_toast_assets(nonce);
11992
11993    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11994    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11995    // of interactive controls — folder watching is managed by the host administrator.
11996    let watched_dirs_html: String = if state.server_mode {
11997        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()
11998    } else {
11999        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
12000            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
12001                .to_string()
12002        } else {
12003            watched_dirs_list
12004                .iter()
12005                .fold(String::new(), |mut s, d| {
12006                    use std::fmt::Write as _;
12007                    let escaped =
12008                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
12009                    write!(
12010                        s,
12011                        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>"#
12012                    ).expect("write to String is infallible");
12013                    s
12014                })
12015        };
12016        format!(
12017            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>"#
12018        )
12019    };
12020
12021    let html = format!(
12022        r##"<!doctype html>
12023<html lang="en">
12024<head>
12025  <meta charset="utf-8" />
12026  <meta name="viewport" content="width=device-width, initial-scale=1" />
12027  <title>OxideSLOC | Trend Reports</title>
12028  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12029  <style nonce="{nonce}">
12030    :root {{
12031      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
12032      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12033      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12034      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12035      --info-bg:#eef3ff; --info-text:#4467d8;
12036    }}
12037    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
12038    *{{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;}}
12039    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
12040    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
12041    .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;}}
12042    @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));}}}}
12043    .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);}}
12044    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
12045    .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));}}
12046    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
12047    .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;}}
12048    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
12049    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
12050    @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; }} }}
12051    .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;}}
12052    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
12053    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
12054    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
12055    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
12056    .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;}}
12057    .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;}}
12058    .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;}}
12059    .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;}}
12060    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
12061    .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);}}
12062    .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;}}
12063    .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;}}
12064    .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;}}
12065    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
12066    .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;}}
12067    .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);}}
12068    .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;}}
12069    .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;}}
12070    .tz-select:focus{{border-color:var(--oxide);}}
12071    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
12072    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
12073    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
12074    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
12075    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
12076    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
12077    .trend-title-block{{flex:1;min-width:0;}}
12078    .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;}}
12079    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
12080    .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;}}
12081    .chart-select:focus{{border-color:var(--accent);}}
12082    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
12083    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
12084    .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);}}
12085    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
12086    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
12087    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
12088    .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);}}
12089    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
12090    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
12091    .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;}}
12092    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
12093    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
12094    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
12095    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
12096    .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;}}
12097    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
12098    .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;}}
12099    .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);}}
12100    .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;}}
12101    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
12102    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
12103    .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);}}
12104    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
12105    .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;}}
12106    .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;}}
12107    .data-table tr:last-child td{{border-bottom:none;}}
12108    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
12109    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
12110    .table-wrap{{width:100%;overflow-x:auto;}}
12111    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
12112    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
12113    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
12114    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
12115    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
12116    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
12117    .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;}}
12118    .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;}}
12119    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
12120    .pagination-info{{font-size:13px;color:var(--muted);}}
12121    .pagination-btns{{display:flex;gap:6px;}}
12122    .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;}}
12123    .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;}}
12124    #scan-history-table col:nth-child(1){{width:155px;}}
12125    #scan-history-table col:nth-child(2){{width:240px;}}
12126    #scan-history-table col:nth-child(3){{width:82px;}}
12127    #scan-history-table col:nth-child(4){{width:82px;}}
12128    #scan-history-table col:nth-child(5){{width:90px;}}
12129    #scan-history-table col:nth-child(6){{width:90px;}}
12130    #scan-history-table col:nth-child(7){{width:88px;}}
12131    #scan-history-table col:nth-child(8){{width:150px;}}
12132    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
12133    .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;}}
12134    .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;}}
12135    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
12136    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
12137    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
12138    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
12139    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
12140    .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;}}
12141    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
12142    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
12143    .watched-chip-rm:hover{{color:var(--oxide);}}
12144    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
12145    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
12146    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
12147    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
12148    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
12149    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
12150    a.run-link:hover{{text-decoration:underline;}}
12151    .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);}}
12152    .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);}}
12153    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
12154    .metric-num{{font-weight:700;color:var(--text);}}
12155    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
12156    .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;}}
12157    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
12158    .btn.primary:hover{{opacity:.9;}}
12159    .rpt-btn{{min-width:58px;justify-content:center;}}
12160    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
12161    .report-cell{{overflow:visible!important;white-space:normal!important;}}
12162    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
12163    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
12164    .submod-details summary::-webkit-details-marker{{display:none;}}
12165    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
12166    .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;}}
12167    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
12168    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
12169    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
12170    .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;}}
12171    .export-btn:hover{{background:var(--line);}}
12172    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
12173    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
12174    .site-footer a{{color:var(--muted);}}
12175    .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;}}
12176    .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;}}
12177    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
12178    /* Modal system (Retention Policy / Clean-up) */
12179    .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;}}
12180    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
12181    .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);}}
12182    .tr-modal{{background:rgba(255,255,255,0.90);}}
12183    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
12184    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
12185    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
12186    .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);}}
12187    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
12188    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
12189    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
12190    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
12191    .tr-modal-body{{padding:22px 30px;}}
12192    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
12193    .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;}}
12194    .tr-btn:hover{{transform:translateY(-1px);}}
12195    .tr-btn:active{{transform:translateY(0);}}
12196    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
12197    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
12198    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
12199    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
12200    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
12201    .tr-btn-secondary:hover{{background:var(--line);}}
12202    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
12203    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
12204  </style>
12205</head>
12206<body>
12207  <div class="background-watermarks" aria-hidden="true">
12208    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12209    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12210    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12211    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12212    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12213    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12214  </div>
12215  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12216  <div class="top-nav">
12217    <div class="top-nav-inner">
12218      <a class="brand" href="/">
12219        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12220        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
12221      </a>
12222      <div class="nav-right">
12223        <a class="nav-pill" href="/">Home</a>
12224        <div class="nav-dropdown">
12225          <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>
12226          <div class="nav-dropdown-menu">
12227            <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>
12228          </div>
12229        </div>
12230        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12231        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12232        <div class="nav-dropdown">
12233          <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>
12234          <div class="nav-dropdown-menu">
12235            <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>
12236          </div>
12237        </div>
12238        <div class="server-status-wrap" id="server-status-wrap">
12239          <div class="nav-pill server-online-pill" id="server-status-pill">
12240            <span class="status-dot" id="status-dot"></span>
12241            <span id="server-status-label">Server</span>
12242            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12243          </div>
12244          <div class="server-status-tip">
12245            OxideSLOC is running — accessible on your network.
12246            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12247          </div>
12248        </div>
12249        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12250          <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>
12251        </button>
12252        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12253          <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>
12254          <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>
12255        </button>
12256      </div>
12257    </div>
12258  </div>
12259
12260  <div class="page">
12261    {watched_dirs_html}
12262    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12263      <div class="scan-overlay-card">
12264        <div class="scan-spinner"></div>
12265        <div class="scan-overlay-text">Scanning folder…</div>
12266        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12267      </div>
12268    </div>
12269    <style>
12270    .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);}}
12271    .scan-overlay.active{{display:flex;}}
12272    .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;}}
12273    .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;}}
12274    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12275    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12276    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12277    </style>
12278    <div class="summary-strip" id="trend-stats"></div>
12279    <div class="panel">
12280      <div class="trend-header">
12281        <div class="trend-title-block">
12282          <h1>Trend Reports</h1>
12283          <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>
12284          <span class="chart-hint-inline">
12285            <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>
12286            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
12287          </span>
12288        </div>
12289        <div class="chart-actions">
12290          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12291            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12292            Retention Policy
12293          </button>
12294          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12295            <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>
12296            Clean up old runs
12297          </button>
12298          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12299            <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>
12300            Export Excel
12301          </button>
12302          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12303            <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>
12304            Export PNG
12305          </button>
12306          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12307            <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>
12308            Export PDF
12309          </button>
12310        </div>
12311      </div>
12312
12313      <div class="controls-centered">
12314        <label>Project Root:
12315          <select class="chart-select" id="root-sel">
12316            <option value="">All projects</option>
12317          </select>
12318        </label>
12319        <label>Y Metric:
12320          <select class="chart-select" id="y-sel">
12321            <option value="code_lines">Code Lines</option>
12322            <option value="comment_lines">Comment Lines</option>
12323            <option value="blank_lines">Blank Lines</option>
12324            <option value="physical_lines">Physical Lines</option>
12325            <option value="files_analyzed">Files Analyzed</option>
12326          </select>
12327        </label>
12328        <label>X Axis:
12329          <select class="chart-select" id="x-sel">
12330            <option value="time">By Time</option>
12331            <option value="commit" selected>By Commit</option>
12332            <option value="release">By Release</option>
12333            <option value="tag">Tagged Commits</option>
12334          </select>
12335        </label>
12336        <label id="submodule-label" style="display:none;">Submodule:
12337          <select class="chart-select" id="sub-sel">
12338            <option value="">All (project total)</option>
12339          </select>
12340        </label>
12341        <label>Chart Size:
12342          <select class="chart-select" id="scale-sel">
12343            <option value="0.75">Compact</option>
12344            <option value="1.2" selected>Normal</option>
12345            <option value="1.38">Large</option>
12346          </select>
12347        </label>
12348        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
12349      </div>
12350
12351      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12352      <div id="data-table-wrap" style="overflow-x:auto;"></div>
12353    </div>
12354  </div>
12355
12356  <script nonce="{nonce}">
12357    (function() {{
12358      // Theme persistence
12359      var b = document.body;
12360      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12361      var tgl = document.getElementById('theme-toggle');
12362      if (tgl) tgl.addEventListener('click', function() {{
12363        var d = b.classList.toggle('dark-theme');
12364        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12365      }});
12366
12367      // Watermark randomizer
12368      (function() {{
12369        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12370        if (!wms.length) return;
12371        var placed = [];
12372        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;}}
12373        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];}}
12374        var half=Math.floor(wms.length/2);
12375        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;}});
12376      }})();
12377
12378      // Code particles
12379      (function() {{
12380        var container = document.getElementById('code-particles');
12381        if (!container) return;
12382        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'];
12383        for (var i = 0; i < 38; i++) {{
12384          (function(idx) {{
12385            var el = document.createElement('span');
12386            el.className = 'code-particle';
12387            el.textContent = snippets[idx % snippets.length];
12388            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12389            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12390            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12391            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';
12392            container.appendChild(el);
12393          }})(i);
12394        }}
12395      }})();
12396
12397      // Watched folder picker
12398      (function(){{
12399        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');}};
12400        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);
12401      }})();
12402      (function() {{
12403        var btn = document.getElementById('add-watched-btn');
12404        if (!btn) return;
12405        btn.addEventListener('click', function() {{
12406          fetch('/pick-directory?kind=reports')
12407            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12408            .then(function(data) {{
12409              if (!data.cancelled && data.selected_path) {{
12410                var form = document.createElement('form');
12411                form.method = 'POST';
12412                form.action = '/watched-dirs/add';
12413                var ri = document.createElement('input');
12414                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12415                var fi = document.createElement('input');
12416                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12417                form.appendChild(ri); form.appendChild(fi);
12418                document.body.appendChild(form);
12419                if (window.__scanOverlay) window.__scanOverlay();
12420                form.submit();
12421              }}
12422            }})
12423            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12424        }});
12425      }})();
12426
12427      // Settings / color-scheme modal
12428      (function() {{
12429        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'}}];
12430        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);}});}}
12431        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12432        var btn=document.getElementById('settings-btn');if(!btn)return;
12433        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12434        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>';
12435        document.body.appendChild(m);
12436        var g=document.getElementById('scheme-grid');
12437        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);}});
12438        var cl=document.getElementById('settings-close');
12439        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);}});}})();
12440        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');}});
12441        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12442        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12443      }})();
12444    }})();
12445
12446    var ROOTS = {roots_json};
12447    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12448    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12449    var allData = [];
12450
12451    // Populate root selector
12452    var rootSel = document.getElementById('root-sel');
12453    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12454
12455    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();}}
12456    function fmtFull(n){{return Number(n).toLocaleString();}}
12457    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
12458
12459    // Tooltip
12460    var tt = document.createElement('div');
12461    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);';
12462    document.body.appendChild(tt);
12463    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12464    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';}}
12465    function hideTT(){{tt.style.display='none';}}
12466    window.addEventListener('blur',function(){{hideTT();}});
12467    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12468
12469    function statExact(compact, full){{
12470      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12471    }}
12472    function statVal(n){{
12473      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12474    }}
12475
12476    function updateStats(data){{
12477      var statsEl=document.getElementById('trend-stats');
12478      if(!statsEl)return;
12479      if(!data||!data.length){{statsEl.innerHTML='';return;}}
12480      var yKey=document.getElementById('y-sel').value;
12481      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12482      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12483      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12484      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12485      var absDelta=Math.abs(delta);
12486      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12487      var deltaExact=statExact(deltaCompact,deltaFull);
12488      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12489      statsEl.innerHTML=
12490        '<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>'+
12491        '<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>'+
12492        '<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>'+
12493        '<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>';
12494    }}
12495
12496    var subSel = document.getElementById('sub-sel');
12497    var subLabel = document.getElementById('submodule-label');
12498
12499    function populateSubmodules(root){{
12500      if(!subSel||!subLabel)return;
12501      while(subSel.options.length>1)subSel.remove(1);
12502      subSel.value='';
12503      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12504      fetch(url)
12505        .then(function(r){{return r.json();}})
12506        .then(function(subs){{
12507          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12508          subs.forEach(function(s){{
12509            var o=document.createElement('option');
12510            o.value=s.name;
12511            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12512            subSel.appendChild(o);
12513          }});
12514          subLabel.style.display='';
12515        }})
12516        .catch(function(){{subLabel.style.display='none';}});
12517    }}
12518
12519    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12520
12521    function loadAndRender(){{
12522      var root = rootSel.value;
12523      var sub = subSel ? subSel.value : '';
12524      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12525      document.getElementById('data-table-wrap').innerHTML='';
12526      var url = '/api/metrics/history?limit=100'
12527        + (root ? '&root='+encodeURIComponent(root) : '')
12528        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
12529      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12530        allData = data;
12531        render(data);
12532        updateStats(data);
12533      }}).catch(function(){{
12534        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>';
12535      }});
12536    }}
12537
12538    function render(data){{
12539      var yKey = document.getElementById('y-sel').value;
12540      var xMode = document.getElementById('x-sel').value;
12541
12542      // Filter for tag/release mode
12543      var pts = data;
12544      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12545
12546      // Sort oldest-first for the line chart
12547      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12548
12549      var wrap = document.getElementById('chart-wrap');
12550      if(!pts.length){{
12551        var emptyMsg = (xMode === 'tag')
12552          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12553          : 'No scan data found for the selected filters.';
12554        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12555        renderTable([]);
12556        return;
12557      }}
12558
12559      var scaleEl=document.getElementById('scale-sel');
12560      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12561      renderTrendInto(wrap, pts, yKey, xMode, sc);
12562      renderTable(pts, yKey);
12563    }}
12564
12565    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12566    // Shared by the inline chart and the Full View modal so both render identically.
12567    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12568      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12569      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12570      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12571      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;
12572      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12573
12574      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12575
12576      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">';
12577      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>';
12578
12579      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12580
12581      // Grid + Y axis ticks
12582      for(var ti=0;ti<=5;ti++){{
12583        var gy=PT+CH-Math.round(ti/5*CH);
12584        var gv=Math.round(ti/5*maxY);
12585        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12586        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12587      }}
12588
12589      // X axis labels (every N-th point to avoid crowding)
12590      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12591      pts.forEach(function(d,i){{
12592        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12593        if(i%labelEvery===0||i===pts.length-1){{
12594          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)));
12595          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>';
12596        }}
12597      }});
12598
12599      // Axis label
12600      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12601      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>';
12602      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>';
12603
12604      // Area fill + line path
12605      var pathD='';
12606      pts.forEach(function(d,i){{
12607        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12608        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12609        pathD+=(i===0?'M':'L')+x+','+y;
12610      }});
12611      if(pts.length>1){{
12612        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12613        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12614      }}
12615      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12616
12617      // Data points (clickable) + permanent value labels
12618      var showLabels = pts.length <= 40;
12619      var labelEveryN = pts.length > 20 ? 2 : 1;
12620      pts.forEach(function(d,i){{
12621        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12622        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12623        var hasTags=d.tags&&d.tags.length>0;
12624        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12625        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12626        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+'"/>';
12627        if(showLabels && i%labelEveryN===0){{
12628          var lx=x, ly=y-r-5;
12629          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>';
12630        }}
12631      }});
12632
12633      svg+='</svg>';
12634      wrap.innerHTML=svg;
12635
12636      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12637      function lineYAt(mx){{
12638        var n=pts.length;
12639        if(n===0)return PT+CH;
12640        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12641        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12642        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12643        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12644        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12645        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12646        return y0+t*(y1-y0);
12647      }}
12648
12649      // SVG-level mousemove: show the value tooltip only when the pointer is over the
12650      // gradient fill (inside the chart and at/below the line) — never in the empty
12651      // space above the line. Cursor follows the same rule.
12652      (function(){{
12653        var svgEl=wrap.querySelector('svg');
12654        if(!svgEl)return;
12655        svgEl.addEventListener('mousemove',function(e){{
12656          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12657          var rect=svgEl.getBoundingClientRect();
12658          var scaleX=W/Math.max(rect.width,1);
12659          var scaleY=H/Math.max(rect.height,1);
12660          var mouseX=(e.clientX-rect.left)*scaleX;
12661          var mouseY=(e.clientY-rect.top)*scaleY;
12662          var ly=lineYAt(mouseX);
12663          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12664          svgEl.style.cursor='pointer';
12665          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12666          var d=pts[idx];
12667          var val=Number(d[yKey]);
12668          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12669          showTT(e,
12670            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12671            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12672            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12673          );
12674        }});
12675        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12676      }})();
12677
12678      // Attach point tooltips
12679      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12680        c.addEventListener('mouseover',function(e){{
12681          var d=pts[parseInt(this.dataset.idx)];
12682          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(''):'';
12683          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>':'';
12684          showTT(e,
12685            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12686            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12687            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12688            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12689          );
12690          this.setAttribute('r','8');
12691        }});
12692        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12693        c.addEventListener('mousemove',moveTT);
12694        c.addEventListener('click',function(){{
12695          var d=pts[parseInt(this.dataset.idx)];
12696          if(d.html_url) window.open(d.html_url,'_blank');
12697        }});
12698      }});
12699    }}
12700
12701    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12702    var shProjFilter='', shBranchFilter='';
12703
12704    function fmtPST(isoStr){{
12705      if(!isoStr)return'';
12706      var d=new Date(isoStr);
12707      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12708      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);}}
12709      function p(n){{return n<10?'0'+n:String(n);}}
12710      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++;}}}}
12711      var yr=d.getUTCFullYear();
12712      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12713      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12714      var isDST=d>=dstStart&&d<dstEnd;
12715      var off=isDST?-7*3600*1000:-8*3600*1000;
12716      var lbl=isDST?'PDT':'PST';
12717      var loc=new Date(d.getTime()+off);
12718      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12719    }}
12720
12721    function getShRows(){{
12722      var proj=shProjFilter.toLowerCase().trim();
12723      var branch=shBranchFilter;
12724      return shData.filter(function(d){{
12725        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12726        if(branch&&(d.branch||'')!==branch)return false;
12727        return true;
12728      }});
12729    }}
12730
12731    function renderShPage(){{
12732      var filtered=getShRows();
12733      if(shSortCol){{
12734        filtered.sort(function(a,b){{
12735          var va,vb;
12736          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12737          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12738          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12739          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12740          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12741          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12742        }});
12743      }}
12744      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12745      shPage=Math.min(shPage,totalPages);
12746      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12747      var visible=filtered.slice(start,end);
12748      var tbody=document.getElementById('sh-tbody');
12749      if(!tbody)return;
12750      tbody.innerHTML=visible.map(function(d){{
12751        var tsHtml=esc(fmtPST(d.timestamp));
12752        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>';
12753        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>';
12754        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12755        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12756        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12757        var reportCell='';
12758        if(d.html_url){{
12759          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12760          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>';}}
12761          reportCell+='</div>';
12762        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12763        if(d.submodule_links&&d.submodule_links.length){{
12764          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12765          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12766          reportCell+='</div></details>';
12767        }}
12768        return '<tr>'
12769          +'<td>'+tsHtml+'</td>'
12770          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12771          +'<td>'+runIdHtml+'</td>'
12772          +'<td>'+commitHtml+'</td>'
12773          +'<td>'+branchHtml+'</td>'
12774          +'<td>'+tags+'</td>'
12775          +'<td class="num">'+metricHtml+'</td>'
12776          +'<td class="report-cell">'+reportCell+'</td>'
12777          +'</tr>';
12778      }}).join('');
12779      var pgRange=document.getElementById('sh-pg-range');
12780      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12781      var pgInfo=document.getElementById('sh-pg-info');
12782      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12783      var pgBtns=document.getElementById('sh-pg-btns');
12784      if(pgBtns){{
12785        pgBtns.innerHTML='';
12786        function mkPgBtn(lbl,pg,active,disabled){{
12787          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12788          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12789          return b;
12790        }}
12791        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12792        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12793        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12794        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12795      }}
12796    }}
12797
12798    function wireTableBehavior(){{
12799      var pf=document.getElementById('sh-proj-filter');
12800      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12801      var bf=document.getElementById('sh-branch-filter');
12802      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12803      var rb=document.getElementById('sh-reset-btn');
12804      if(rb)rb.addEventListener('click',function(){{
12805        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12806        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12807        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12808        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');}});
12809        renderShPage();
12810      }});
12811      var pps=document.getElementById('sh-per-page');
12812      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12813      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12814      ths.forEach(function(th){{
12815        th.addEventListener('click',function(e){{
12816          if(e.target.classList.contains('col-resize-handle'))return;
12817          var col=th.dataset.col;
12818          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12819          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12820          th.classList.add('sort-'+shSortOrder);
12821          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12822          shPage=1;renderShPage();
12823        }});
12824      }});
12825      var table=document.getElementById('scan-history-table');
12826      if(!table)return;
12827      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12828      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12829      allThs.forEach(function(th,i){{
12830        var handle=th.querySelector('.col-resize-handle');
12831        if(!handle||!cols[i])return;
12832        var startX,startW;
12833        handle.addEventListener('mousedown',function(e){{
12834          e.stopPropagation();e.preventDefault();
12835          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12836          handle.classList.add('dragging');
12837          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12838          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12839          document.addEventListener('mousemove',onMove);
12840          document.addEventListener('mouseup',onUp);
12841        }});
12842      }});
12843    }}
12844
12845    function renderTable(pts, yKey){{
12846      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12847      var wrap=document.getElementById('data-table-wrap');
12848      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12849      var yLabel=Y_LABELS[yKey]||yKey||'';
12850      shData=pts.slice().reverse();
12851      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12852      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12853      var branches={{}};
12854      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12855      var branchOpts='<option value="">All branches</option>';
12856      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12857      wrap.innerHTML=
12858        '<div class="chart-section-header">SCAN HISTORY</div>'+
12859        '<div class="filter-row">'+
12860          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12861          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12862          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12863        '</div>'+
12864        '<div class="table-wrap">'+
12865        '<table id="scan-history-table" class="data-table">'+
12866        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12867        '<thead><tr id="sh-thead">'+
12868        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12869        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12870        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12871        '<th>Commit<div class="col-resize-handle"></div></th>'+
12872        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12873        '<th>Tags<div class="col-resize-handle"></div></th>'+
12874        '<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>'+
12875        '<th>Report<div class="col-resize-handle"></div></th>'+
12876        '</tr></thead>'+
12877        '<tbody id="sh-tbody"></tbody>'+
12878        '</table>'+
12879        '</div>'+
12880        '<div class="pagination">'+
12881          '<span class="pagination-info" id="sh-pg-info"></span>'+
12882          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12883          '<div style="display:flex;align-items:center;gap:8px;">'+
12884            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12885            '<select class="filter-select" id="sh-per-page">'+
12886              '<option value="10">10 per page</option>'+
12887              '<option value="25" selected>25 per page</option>'+
12888              '<option value="50">50 per page</option>'+
12889              '<option value="100">100 per page</option>'+
12890            '</select>'+
12891            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12892          '</div>'+
12893        '</div>';
12894      wireTableBehavior();
12895      renderShPage();
12896    }}
12897
12898    function exportXLSX(){{
12899      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12900      var xbtn=document.getElementById('export-xlsx-btn');
12901      var xorig=xbtn?xbtn.innerHTML:'';
12902      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12903      var root=rootSel.value;
12904      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12905      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12906        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12907        buildAndDownloadXLSX(cm);
12908      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12909    }}
12910
12911    function buildAndDownloadXLSX(churnMap){{
12912      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12913      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12914      // (sorted is newest-first), so a given project/commit appears at most once.
12915      var seenPC={{}},dedup=[];
12916      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12917      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12918      var s1R=dedup.map(function(d){{
12919        var c=churnMap[d.run_id]||{{}};
12920        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)];
12921      }});
12922      var pm={{}};
12923      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12924      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'];
12925      var s2R=Object.keys(pm).map(function(p){{
12926        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12927        var lat=sc[sc.length-1],fst=sc[0];
12928        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12929        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);
12930        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];
12931      }});
12932      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12933      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12934      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12935      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12936    }}
12937
12938    function buildXLSX(sheets,chartRows,chartRows2){{
12939      function s2b(s){{return new TextEncoder().encode(s);}}
12940      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12941      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;}}
12942      function crc32(d){{
12943        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;}}}}
12944        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12945      }}
12946      function buildSheet(hdr,rows,drawRid,withCtrl){{
12947        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12948        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12949        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12950        x+='<row r="1">';
12951        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12952        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12953        x+='</row>';
12954        rows.forEach(function(row,ri){{
12955          var rn=ri+2;
12956          x+='<row r="'+rn+'">';
12957          row.forEach(function(cell,ci){{
12958            var addr=col2l(ci+1)+rn;
12959            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12960            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12961          }});
12962          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>";}}
12963          x+='</row>';
12964        }});
12965        x+='</sheetData>';
12966        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12967        return x+'</worksheet>';
12968      }}
12969      function buildChartXML(rows){{
12970        var sn="'Scan History'";
12971        var nr=rows.length,er=nr+1;
12972        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'}}];
12973        var catCol='C',catIdx=2;
12974        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12975        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">';
12976        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12977        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>';
12978        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12979        sd.forEach(function(s,i){{
12980          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12981          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>';
12982          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12983          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>';
12984          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12985          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12986          x+='</c:strCache></c:strRef></c:cat>';
12987          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+'"/>';
12988          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12989          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12990        }});
12991        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12992        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>';
12993        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>';
12994        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12995        return x;
12996      }}
12997      function buildChartXML2(rows){{
12998        var sn="'By Project'";
12999        var nr=rows.length,er=nr+1;
13000        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'}}];
13001        var catCol='A',catIdx=0;
13002        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13003        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">';
13004        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
13005        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>';
13006        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13007        sd.forEach(function(s,i){{
13008          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
13009          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>';
13010          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
13011          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>';
13012          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13013          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13014          x+='</c:strCache></c:strRef></c:cat>';
13015          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+'"/>';
13016          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
13017          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13018        }});
13019        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
13020        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>';
13021        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>';
13022        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
13023        return x;
13024      }}
13025      function buildChartXML3(rows){{
13026        var sn="'Scan History'";
13027        var nr=rows.length,er=nr+1;
13028        var catCol='C',catIdx=2;
13029        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13030        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">';
13031        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
13032        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
13033        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
13034        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>";
13035        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
13036        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>';
13037        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>';
13038        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
13039        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
13040        x+='</c:strCache></c:strRef></c:cat>';
13041        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+'"/>';
13042        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
13043        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
13044        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
13045        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>';
13046        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>';
13047        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>';
13048        return x;
13049      }}
13050      function buildFocusSheet(drawRid){{
13051        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
13052        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
13053        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
13054        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
13055        x+='<sheetData><row r="1">';
13056        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
13057        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
13058        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
13059        x+='</row></sheetData>';
13060        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>';
13061        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
13062        return x+'</worksheet>';
13063      }}
13064      var hasChart=!!(chartRows&&chartRows.length);
13065      var nr=hasChart?chartRows.length:0;
13066      var hasChart2=!!(chartRows2&&chartRows2.length);
13067      var nr2=hasChart2?chartRows2.length:0;
13068      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>';
13069      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"/>';
13070      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
13071      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"/>';}}
13072      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"/>';}}
13073      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
13074      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>';
13075      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
13076      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"/>';}});
13077      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
13078      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>';
13079      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
13080      wbx+='</sheets></workbook>';
13081      var files=[
13082        {{name:'[Content_Types].xml',data:s2b(ct)}},
13083        {{name:'_rels/.rels',data:s2b(dotrels)}},
13084        {{name:'xl/workbook.xml',data:s2b(wbx)}},
13085        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
13086        {{name:'xl/styles.xml',data:s2b(styl)}}
13087      ];
13088      // Chart embedded directly in Scan History (sheet1); By Project is plain
13089      sheets.forEach(function(s,i){{
13090        var sx;
13091        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
13092        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
13093        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
13094      }});
13095      if(hasChart){{
13096        var fromRow=nr+4,toRow=nr+34;
13097        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>')}});
13098        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13099        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">';
13100        drx+='<xdr:twoCellAnchor editAs="twoCell">';
13101        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>';
13102        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>';
13103        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13104        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13105        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13106        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13107        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13108        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
13109        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>')}});
13110        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
13111        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>')}});
13112        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13113        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">';
13114        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
13115        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>';
13116        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>';
13117        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13118        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13119        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13120        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13121        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
13122        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
13123        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>')}});
13124        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
13125      }}
13126      if(hasChart2){{
13127        var fromRow2=nr2+4,toRow2=nr2+36;
13128        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>')}});
13129        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
13130        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">';
13131        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
13132        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>';
13133        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>';
13134        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
13135        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
13136        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
13137        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
13138        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
13139        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
13140        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>')}});
13141        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
13142      }}
13143      var parts=[],offsets=[],total=0;
13144      files.forEach(function(f){{
13145        offsets.push(total);
13146        var nb=s2b(f.name),crc=crc32(f.data);
13147        var h=new DataView(new ArrayBuffer(30+nb.length));
13148        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
13149        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
13150        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
13151        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
13152        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
13153        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
13154        total+=30+nb.length+f.data.length;
13155      }});
13156      var cdStart=total;
13157      files.forEach(function(f,fi){{
13158        var nb=s2b(f.name),crc=crc32(f.data);
13159        var cd=new DataView(new ArrayBuffer(46+nb.length));
13160        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
13161        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
13162        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
13163        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
13164        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
13165        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
13166        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
13167      }});
13168      var cdSz=total-cdStart;
13169      var eocd=new DataView(new ArrayBuffer(22));
13170      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
13171      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
13172      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
13173      parts.push(new Uint8Array(eocd.buffer));
13174      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
13175      var out=new Uint8Array(sz);var off=0;
13176      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
13177      return out.buffer;
13178    }}
13179
13180    function trendTitleParts(){{
13181      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
13182      var subSelEl=document.getElementById('sub-sel');
13183      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
13184      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
13185      var proj=(document.getElementById('root-sel').value)||'All projects';
13186      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
13187      var cnt=(allData&&allData.length)||0;
13188      var now=new Date();
13189      function p2(n){{return(n<10?'0':'')+n;}}
13190      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
13191      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
13192    }}
13193
13194    function exportPNG(){{
13195      var svgEl=document.querySelector('#chart-wrap svg');
13196      if(!svgEl){{alert('No chart to export yet.');return;}}
13197      var svgStr=new XMLSerializer().serializeToString(svgEl);
13198      var vb=svgEl.viewBox.baseVal,scale=2;
13199      var headerH=84,footerH=36;
13200      var lw=(vb.width||900),lh=(vb.height||380);
13201      var w=lw*scale,h=(lh+headerH+footerH)*scale;
13202      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
13203      var url=URL.createObjectURL(blob);
13204      var img=new Image();
13205      var tp=trendTitleParts();
13206      img.onload=function(){{
13207        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
13208        var ctx=canvas.getContext('2d');
13209        var cs=getComputedStyle(document.body);
13210        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
13211        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
13212        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
13213        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
13214        ctx.scale(scale,scale);
13215        ctx.textBaseline='alphabetic';ctx.textAlign='left';
13216        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
13217        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
13218        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
13219        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;
13220        ctx.drawImage(img,0,headerH);
13221        var fy=headerH+lh;
13222        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;
13223        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
13224        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);
13225        ctx.textAlign='left';
13226        URL.revokeObjectURL(url);
13227        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
13228      }};
13229      img.src=url;
13230    }}
13231
13232    function exportPDF(){{
13233      var svgEl=document.querySelector('#chart-wrap svg');
13234      if(!svgEl){{alert('No chart to export yet.');return;}}
13235      var tp=trendTitleParts();
13236      var svgStr=new XMLSerializer().serializeToString(svgEl);
13237      var statsEl=document.getElementById('trend-stats');
13238      var statsHtml=statsEl?statsEl.innerHTML:'';
13239      var yK=document.getElementById('y-sel').value;
13240      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13241      var yL=yLabels[yK]||yK;
13242      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13243      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>';
13244      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>';}});
13245      tableHtml+='</tbody></table>';
13246      var css='<style>'
13247        +'*{{box-sizing:border-box;}}'
13248        +'html,body{{margin:0;padding:0;}}'
13249        // Masthead/footer flow in document order — a position:fixed header repeats
13250        // on every printed page in Chromium and hides the rows beneath it on pages
13251        // 2+. The trend table's <thead> repeats per page natively instead.
13252        +'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;}}'
13253        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13254        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13255        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13256        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13257        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13258        +'.rep-body{{padding:22px 34px 0;}}'
13259        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13260        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13261        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13262        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13263        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13264        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13265        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13266        +'.stat-chip-tip{{display:none!important;}}'
13267        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13268        +'.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;}}'
13269        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13270        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13271        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13272        +'.rep-chart svg{{max-width:100%;height:auto;}}'
13273        +'.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;}}'
13274        +'.filter-row{{display:none!important;}}'
13275        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13276        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13277        +'th{{background:#f0e9e0;font-weight:800;}}'
13278        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13279        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13280        +'.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;}}'
13281        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13282        +'</style>';
13283      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13284        +'<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>'
13285        +'<div class="rep-body">'
13286        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13287        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13288        +'<div class="summary-strip">'+statsHtml+'</div>'
13289        +'<div class="rep-chart">'+svgStr+'</div>'
13290        +tableHtml
13291        +'</div>'
13292        +'<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>'
13293        +'</body></html>';
13294      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13295    }}
13296
13297    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13298      var el=document.getElementById(id);
13299      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13300    }});
13301    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13302    // tracks the container like the responsive Chart.js charts do.
13303    var _rsT=null;
13304    window.addEventListener('resize',function(){{
13305      if(_rsT)clearTimeout(_rsT);
13306      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13307    }});
13308    rootSel.addEventListener('change',function(){{
13309      populateSubmodules(rootSel.value);
13310      loadAndRender();
13311    }});
13312    if(subSel)subSel.addEventListener('change',loadAndRender);
13313
13314    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13315    (function(){{
13316      var fvBtn=document.getElementById('tr-chart-fv-btn');
13317      if(!fvBtn)return;
13318      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13319      fvBtn.addEventListener('click',function(){{
13320        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13321        var yKey=document.getElementById('y-sel').value;
13322        var xMode=document.getElementById('x-sel').value;
13323        var pts=allData;
13324        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13325        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13326        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13327        var tp=trendTitleParts();
13328        var ov=document.createElement('div');
13329        ov.className='tr-chart-full-modal';
13330        ov.innerHTML='<div class="tr-chart-full-inner">'
13331          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13332          +'<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>'
13333          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13334          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13335          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13336        document.body.appendChild(ov);
13337        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13338        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13339        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13340        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13341        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13342      }});
13343    }})();
13344
13345    var xlsxBtn=document.getElementById('export-xlsx-btn');
13346    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13347    var pngBtn=document.getElementById('export-png-btn');
13348    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13349    var pdfBtn=document.getElementById('export-pdf-btn');
13350    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13351
13352    // ── Clean-up modal ───────────────────────────────────────────────────────
13353    (function(){{
13354      var triggerBtn=document.getElementById('cleanup-runs-btn');
13355      if(!triggerBtn)return;
13356      var modal=document.createElement('div');
13357      modal.className='tr-modal-backdrop';
13358      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13359        +'<div class="tr-modal-head">'
13360        +'<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>'
13361        +'<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>'
13362        +'</div>'
13363        +'<div class="tr-modal-body">'
13364        +'<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>'
13365        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13366        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13367        +'<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;">'
13368        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13369        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13370        +'</div>'
13371        +'<div class="tr-modal-foot">'
13372        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13373        +'<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>'
13374        +'</div></div>';
13375      document.body.appendChild(modal);
13376      triggerBtn.addEventListener('click',function(){{
13377        document.getElementById('cleanup-status').style.display='none';
13378        modal.style.display='flex';
13379      }});
13380      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13381      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13382      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13383        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13384        var confirmBtn=this;
13385        confirmBtn.disabled=true;
13386        var status=document.getElementById('cleanup-status');
13387        status.style.display='block';
13388        status.style.background='#dbeafe';status.style.color='#1e40af';
13389        status.textContent='Deleting\u2026';
13390        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13391        .then(function(resp){{
13392          return resp.json().then(function(d){{
13393            if(resp.ok){{
13394              status.style.background='#dcfce7';status.style.color='#166534';
13395              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13396              setTimeout(function(){{window.location.reload();}},1500);
13397            }}else{{
13398              status.style.background='#fee2e2';status.style.color='#991b1b';
13399              status.textContent='Error: '+(d.error||'Unexpected error');
13400              confirmBtn.disabled=false;
13401            }}
13402          }});
13403        }})
13404        .catch(function(e){{
13405          status.style.background='#fee2e2';status.style.color='#991b1b';
13406          status.textContent='Network error: '+String(e);
13407          confirmBtn.disabled=false;
13408        }});
13409      }});
13410    }})();
13411
13412    // ── Retention policy panel ────────────────────────────────────────────────
13413    (function(){{
13414      var triggerBtn=document.getElementById('retention-policy-btn');
13415      if(!triggerBtn)return;
13416      var modal=document.createElement('div');
13417      modal.className='tr-modal-backdrop';
13418      modal.style.zIndex='9001';
13419      modal.innerHTML=''
13420        +'<div class="tr-modal" style="max-width:640px;">'
13421        +'<div class="tr-modal-head">'
13422        +'<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>'
13423        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13424        +'</div>'
13425        +'<div class="tr-modal-body">'
13426        +'<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>'
13427        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13428        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13429        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13430        +'</div>'
13431        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13432        +'<div>'
13433        +'<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>'
13434        +'<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;">'
13435        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13436        +'</div>'
13437        +'<div>'
13438        +'<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>'
13439        +'<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;">'
13440        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13441        +'</div>'
13442        +'</div>'
13443        +'<div style="margin-bottom:20px;">'
13444        +'<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>'
13445        +'<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;">'
13446        +'<option value="1">Every hour</option>'
13447        +'<option value="6">Every 6 hours</option>'
13448        +'<option value="12">Every 12 hours</option>'
13449        +'<option value="24" selected>Every 24 hours</option>'
13450        +'<option value="48">Every 2 days</option>'
13451        +'<option value="72">Every 3 days</option>'
13452        +'<option value="168">Every week</option>'
13453        +'</select>'
13454        +'</div>'
13455        +'<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>'
13456        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13457        +'</div>'
13458        +'<div class="tr-modal-foot">'
13459        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13460        +'<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>'
13461        +'<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>'
13462        +'</div>'
13463        +'</div>';
13464      document.body.appendChild(modal);
13465
13466      function rpShowStatus(msg,ok){{
13467        var s=document.getElementById('rp-status');
13468        s.style.display='block';
13469        s.style.background=ok?'#dcfce7':'#fee2e2';
13470        s.style.color=ok?'#166534':'#991b1b';
13471        s.textContent=msg;
13472      }}
13473      function fmtAgo(iso){{
13474        if(!iso)return'Never';
13475        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13476        if(diff<60)return diff+'s ago';
13477        if(diff<3600)return Math.floor(diff/60)+'m ago';
13478        if(diff<86400)return Math.floor(diff/3600)+'h ago';
13479        return Math.floor(diff/86400)+'d ago';
13480      }}
13481      function loadPolicy(){{
13482        fetch('/api/cleanup-policy')
13483          .then(function(r){{return r.json();}})
13484          .then(function(d){{
13485            var p=d.policy;
13486            document.getElementById('rp-enabled').checked=p?p.enabled:false;
13487            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13488            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13489            var sel=document.getElementById('rp-interval');
13490            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;}}}}}}
13491            var lr=document.getElementById('rp-last-run');
13492            if(d.last_run_at){{
13493              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'):'');
13494            }}else{{
13495              lr.textContent='Auto-cleanup has not run yet.';
13496            }}
13497          }})
13498          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13499      }}
13500
13501      triggerBtn.addEventListener('click',function(){{
13502        document.getElementById('rp-status').style.display='none';
13503        loadPolicy();
13504        modal.style.display='flex';
13505      }});
13506      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13507      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13508
13509      document.getElementById('rp-save-btn').addEventListener('click',function(){{
13510        var enabled=document.getElementById('rp-enabled').checked;
13511        var ageVal=document.getElementById('rp-max-age').value.trim();
13512        var countVal=document.getElementById('rp-max-count').value.trim();
13513        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13514        if(enabled&&!ageVal&&!countVal){{
13515          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13516          return;
13517        }}
13518        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13519        var saveBtn=document.getElementById('rp-save-btn');
13520        saveBtn.disabled=true;
13521        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13522          .then(function(r){{
13523            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13524            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13525          }})
13526          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13527          .finally(function(){{saveBtn.disabled=false;}});
13528      }});
13529
13530      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13531        var btn=this;
13532        var orig=btn.innerHTML;
13533        btn.disabled=true;
13534        btn.textContent='Running\u2026';
13535        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13536          .then(function(r){{return r.json();}})
13537          .then(function(d){{
13538            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13539            loadPolicy();
13540          }})
13541          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13542          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13543      }});
13544    }})();
13545
13546    populateSubmodules(rootSel.value);
13547    loadAndRender();
13548
13549    (function randomizeWatermarks() {{
13550      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13551      if (!wms.length) return;
13552      var placed = [];
13553      function tooClose(top, left) {{
13554        for (var i = 0; i < placed.length; i++) {{
13555          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13556          if (dt < 16 && dl < 12) return true;
13557        }}
13558        return false;
13559      }}
13560      function pick(leftBand) {{
13561        for (var attempt = 0; attempt < 50; attempt++) {{
13562          var top = Math.random() * 88 + 2;
13563          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13564          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13565        }}
13566        var top = Math.random() * 88 + 2;
13567        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13568        placed.push([top, left]); return [top, left];
13569      }}
13570      var half = Math.floor(wms.length / 2);
13571      wms.forEach(function (img, i) {{
13572        var pos = pick(i < half);
13573        var size = Math.floor(Math.random() * 100 + 120);
13574        var rot = (Math.random() * 360).toFixed(1);
13575        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13576        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;
13577      }});
13578    }})();
13579    (function spawnCodeParticles() {{
13580      var container = document.getElementById('code-particles');
13581      if (!container) return;
13582      var snippets = [
13583        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13584        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13585        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13586        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13587        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13588      ];
13589      var count = 38;
13590      for (var i = 0; i < count; i++) {{
13591        (function(idx) {{
13592          var el = document.createElement('span');
13593          el.className = 'code-particle';
13594          el.textContent = snippets[idx % snippets.length];
13595          var left = Math.random() * 94 + 2;
13596          var top = Math.random() * 88 + 6;
13597          var dur = (Math.random() * 10 + 9).toFixed(1);
13598          var delay = (Math.random() * 18).toFixed(1);
13599          var rot = (Math.random() * 26 - 13).toFixed(1);
13600          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13601          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13602          container.appendChild(el);
13603        }})(i);
13604      }}
13605    }})();
13606  </script>
13607  <footer class="site-footer">
13608    local code analysis - metrics, history and reports
13609    &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>
13610    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13611    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13612    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13613    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13614  </footer>
13615  <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>
13616  {toast_assets}
13617</body>
13618</html>"##,
13619    );
13620
13621    Html(html).into_response()
13622}
13623
13624fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13625    use std::collections::HashMap;
13626    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13627        return vec![];
13628    }
13629    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13630    for rec in per_file_records {
13631        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13632            let e = totals.entry(lang.display_name().to_string()).or_default();
13633            e.0 += u64::from(cov.lines_found);
13634            e.1 += u64::from(cov.lines_hit);
13635        }
13636    }
13637    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13638    let mut pairs: Vec<(String, f64)> = totals
13639        .into_iter()
13640        .filter(|(_, (found, _))| *found > 0)
13641        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13642        .collect();
13643    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13644    pairs
13645        .iter()
13646        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13647        .collect()
13648}
13649
13650fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13651    let mut high = 0u64;
13652    let mut mid = 0u64;
13653    let mut low = 0u64;
13654    for rec in per_file_records {
13655        if let Some(cov) = &rec.coverage {
13656            if cov.lines_found == 0 {
13657                continue;
13658            }
13659            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13660            if pct >= 80.0 {
13661                high += 1;
13662            } else if pct >= 50.0 {
13663                mid += 1;
13664            } else {
13665                low += 1;
13666            }
13667        }
13668    }
13669    (high, mid, low)
13670}
13671
13672fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13673    let mut arr: Vec<serde_json::Value> = per_file_records
13674        .iter()
13675        .filter_map(|rec| {
13676            rec.coverage.as_ref().map(|cov| {
13677                let line_pct = if cov.lines_found > 0 {
13678                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13679                        / 10.0
13680                } else {
13681                    0.0
13682                };
13683                let fn_pct = if cov.functions_found > 0 {
13684                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13685                        .round()
13686                        / 10.0
13687                } else {
13688                    -1.0
13689                };
13690                serde_json::json!({
13691                    "rel": rec.relative_path,
13692                    "lang": rec.language.map_or("?", |l| l.display_name()),
13693                    "line_pct": line_pct,
13694                    "fn_pct": fn_pct,
13695                    "lhit": cov.lines_hit,
13696                    "lfound": cov.lines_found,
13697                    "fhit": cov.functions_hit,
13698                    "ffound": cov.functions_found,
13699                })
13700            })
13701        })
13702        .collect();
13703    arr.sort_by(|a, b| {
13704        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13705        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13706        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13707    });
13708    arr
13709}
13710
13711#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13712fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13713    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13714        .totals_by_language
13715        .iter()
13716        .filter(|l| l.test_count > 0)
13717        .collect();
13718    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13719    let lang_tests: Vec<serde_json::Value> = langs
13720        .iter()
13721        .map(|l| {
13722            let d = if l.code_lines > 0 {
13723                l.test_count as f64 / l.code_lines as f64 * 1000.0
13724            } else {
13725                0.0
13726            };
13727            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13728                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13729                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13730        })
13731        .collect();
13732    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13733    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13734    let t = &run.summary_totals;
13735    let total_tests = t.test_count;
13736    let density = if t.code_lines > 0 {
13737        total_tests as f64 / t.code_lines as f64 * 1000.0
13738    } else {
13739        0.0
13740    };
13741    let most_tested = langs.first().map_or_else(
13742        || "\u{2014}".to_string(),
13743        |l| l.language.display_name().to_string(),
13744    );
13745    let test_files: u64 = run
13746        .per_file_records
13747        .iter()
13748        .filter(|f| f.raw_line_categories.test_count > 0)
13749        .count() as u64;
13750    let cov_line = if t.coverage_lines_found > 0 {
13751        format!(
13752            "{:.1}",
13753            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13754        )
13755    } else {
13756        "0".to_string()
13757    };
13758    let cov_fn = if t.coverage_functions_found > 0 {
13759        format!(
13760            "{:.1}",
13761            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13762        )
13763    } else {
13764        "0".to_string()
13765    };
13766    let cov_branch = if t.coverage_branches_found > 0 {
13767        format!(
13768            "{:.1}",
13769            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13770        )
13771    } else {
13772        "0".to_string()
13773    };
13774    let has_cov = !cov_arr.is_empty();
13775    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13776    serde_json::json!({
13777        "totals": {
13778            "test_count": total_tests,
13779            "assertions": t.test_assertion_count,
13780            "suites": t.test_suite_count,
13781            "test_files": test_files,
13782            "total_files": t.files_analyzed,
13783            "density_str": format!("{density:.1}"),
13784            "most_tested": most_tested,
13785            "langs_with_tests": langs.len(),
13786            "cov_line": cov_line,
13787            "cov_fn": cov_fn,
13788            "cov_branch": cov_branch,
13789        },
13790        "lang_tests": lang_tests,
13791        "cov": cov_arr,
13792        "cov_tiers": {"high": high, "mid": mid, "low": low},
13793        "file_cov": file_cov_arr,
13794        "has_coverage": has_cov,
13795        "submodules": {},
13796    })
13797}
13798
13799#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13800fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13801    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13802        .language_summaries
13803        .iter()
13804        .filter(|l| l.test_count > 0)
13805        .collect();
13806    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13807    let lang_tests: Vec<serde_json::Value> = langs
13808        .iter()
13809        .map(|l| {
13810            let d = if l.code_lines > 0 {
13811                l.test_count as f64 / l.code_lines as f64 * 1000.0
13812            } else {
13813                0.0
13814            };
13815            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13816                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13817                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13818        })
13819        .collect();
13820    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13821    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13822    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13823    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13824    let density = if sub.code_lines > 0 {
13825        total_tests as f64 / sub.code_lines as f64 * 1000.0
13826    } else {
13827        0.0
13828    };
13829    let most_tested = langs.first().map_or_else(
13830        || "\u{2014}".to_string(),
13831        |l| l.language.display_name().to_string(),
13832    );
13833    serde_json::json!({
13834        "totals": {
13835            "test_count": total_tests,
13836            "assertions": total_assertions,
13837            "suites": total_suites,
13838            "test_files": test_files_approx,
13839            "total_files": sub.files_analyzed,
13840            "density_str": format!("{density:.1}"),
13841            "most_tested": most_tested,
13842            "langs_with_tests": langs.len(),
13843            "cov_line": "0",
13844            "cov_fn": "0",
13845            "cov_branch": "0",
13846        },
13847        "lang_tests": lang_tests,
13848        "cov": [],
13849        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13850        "has_coverage": false,
13851    })
13852}
13853
13854fn compute_cov_json_str(run: &AnalysisRun) -> String {
13855    use std::collections::HashMap;
13856    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13857    for rec in &run.per_file_records {
13858        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13859            let e = totals.entry(lang.display_name().to_string()).or_default();
13860            e.0 += u64::from(cov.lines_found);
13861            e.1 += u64::from(cov.lines_hit);
13862        }
13863    }
13864    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13865    let mut pairs: Vec<(String, f64)> = totals
13866        .into_iter()
13867        .filter(|(_, (found, _))| *found > 0)
13868        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13869        .collect();
13870    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13871    let parts: Vec<String> = pairs
13872        .iter()
13873        .map(|(lang, pct)| {
13874            let name = lang.replace('"', "\\\"");
13875            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13876        })
13877        .collect();
13878    format!("[{}]", parts.join(","))
13879}
13880
13881fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13882    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13883    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13884}
13885
13886fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13887    let mut entry = build_test_scope_entry(run);
13888    if !run.submodule_summaries.is_empty() {
13889        let subs: serde_json::Map<String, serde_json::Value> = run
13890            .submodule_summaries
13891            .iter()
13892            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13893            .collect();
13894        entry["submodules"] = serde_json::Value::Object(subs);
13895    }
13896    entry
13897}
13898
13899fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13900    let name = l.language.display_name().replace('"', "\\\"");
13901    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13902    let density = if l.code_lines > 0 {
13903        l.test_count as f64 / l.code_lines as f64 * 1000.0
13904    } else {
13905        0.0
13906    };
13907    format!(
13908        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13909        name = name,
13910        t = l.test_count,
13911        a = l.test_assertion_count,
13912        s = l.test_suite_count,
13913        c = l.code_lines,
13914        d = density,
13915        f = l.files,
13916    )
13917}
13918
13919fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13920    let Some(r) = run else {
13921        return "[]".to_string();
13922    };
13923    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13924        .totals_by_language
13925        .iter()
13926        .filter(|l| l.test_count > 0)
13927        .collect();
13928    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13929    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13930    format!("[{}]", parts.join(","))
13931}
13932
13933/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13934async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13935    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13936    scope_map.insert(
13937        "__all__".to_string(),
13938        latest_run.map_or_else(
13939            || {
13940                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13941                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13942                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13943                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13944                    "has_coverage":false,"submodules":{}})
13945            },
13946            build_test_scope_entry,
13947        ),
13948    );
13949    let all_roots: Vec<String> = {
13950        let reg = state.registry.lock().await;
13951        let mut seen = std::collections::BTreeSet::new();
13952        reg.entries
13953            .iter()
13954            .flat_map(|e| e.input_roots.iter().cloned())
13955            .filter(|r| seen.insert(r.clone()))
13956            .collect()
13957    };
13958    for root in &all_roots {
13959        let json_path = {
13960            let reg = state.registry.lock().await;
13961            reg.entries
13962                .iter()
13963                .find(|e| e.input_roots.iter().any(|r| r == root))
13964                .and_then(|e| e.json_path.clone())
13965        };
13966        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13967            let json_str = tokio::fs::read_to_string(&p).await.ok();
13968            json_str
13969                .as_deref()
13970                .and_then(|s| serde_json::from_str(s).ok())
13971        } else {
13972            None
13973        };
13974        if let Some(ref run) = run_for_root {
13975            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13976        }
13977    }
13978    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13979}
13980
13981// GET /test-metrics
13982#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13983#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13984async fn test_metrics_handler(
13985    State(state): State<AppState>,
13986    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13987) -> Response {
13988    auto_scan_watched_dirs(&state).await;
13989    let watched_dirs_list: Vec<String> = {
13990        let wd = state.watched_dirs.lock().await;
13991        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13992    };
13993    let latest_run: Option<AnalysisRun> = {
13994        let json_path = {
13995            let reg = state.registry.lock().await;
13996            reg.entries.first().and_then(|e| e.json_path.clone())
13997        };
13998        if let Some(p) = json_path {
13999            let json_str = tokio::fs::read_to_string(&p).await.ok();
14000            json_str
14001                .as_deref()
14002                .and_then(|s| serde_json::from_str(s).ok())
14003        } else {
14004            None
14005        }
14006    };
14007
14008    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
14009    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
14010
14011    // Build coverage chart JSON (per-language avg line coverage %).
14012    let cov_json: String = latest_run
14013        .as_ref()
14014        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14015        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
14016
14017    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
14018    let _cov_tier_json: String = latest_run
14019        .as_ref()
14020        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
14021        .map_or_else(
14022            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
14023            compute_cov_tier_json_str,
14024        );
14025
14026    let total_tests: u64 = latest_run
14027        .as_ref()
14028        .map_or(0, |r| r.summary_totals.test_count);
14029    let total_assertions: u64 = latest_run
14030        .as_ref()
14031        .map_or(0, |r| r.summary_totals.test_assertion_count);
14032    let total_suites: u64 = latest_run
14033        .as_ref()
14034        .map_or(0, |r| r.summary_totals.test_suite_count);
14035    let total_code: u64 = latest_run
14036        .as_ref()
14037        .map_or(0, |r| r.summary_totals.code_lines);
14038    let workspace_density: f64 = if total_code > 0 {
14039        total_tests as f64 / total_code as f64 * 1000.0
14040    } else {
14041        0.0
14042    };
14043    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
14044        r.totals_by_language
14045            .iter()
14046            .filter(|l| l.test_count > 0)
14047            .count()
14048    });
14049    let most_tested: String = latest_run
14050        .as_ref()
14051        .and_then(|r| {
14052            r.totals_by_language
14053                .iter()
14054                .filter(|l| l.test_count > 0)
14055                .max_by_key(|l| l.test_count)
14056        })
14057        .map_or_else(
14058            || "\u{2014}".to_string(),
14059            |l| l.language.display_name().to_string(),
14060        );
14061    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
14062        r.per_file_records
14063            .iter()
14064            .filter(|f| f.raw_line_categories.test_count > 0)
14065            .count() as u64
14066    });
14067    let total_files_analyzed: u64 = latest_run
14068        .as_ref()
14069        .map_or(0, |r| r.summary_totals.files_analyzed);
14070    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
14071
14072    // Aggregated coverage percentages from summary_totals
14073    let cov_line_pct_str: String = latest_run
14074        .as_ref()
14075        .filter(|r| r.summary_totals.coverage_lines_found > 0)
14076        .map_or_else(
14077            || "0".to_string(),
14078            |r| {
14079                format!(
14080                    "{:.1}",
14081                    r.summary_totals.coverage_lines_hit as f64
14082                        / r.summary_totals.coverage_lines_found as f64
14083                        * 100.0
14084                )
14085            },
14086        );
14087    let cov_fn_pct_str: String = latest_run
14088        .as_ref()
14089        .filter(|r| r.summary_totals.coverage_functions_found > 0)
14090        .map_or_else(
14091            || "0".to_string(),
14092            |r| {
14093                format!(
14094                    "{:.1}",
14095                    r.summary_totals.coverage_functions_hit as f64
14096                        / r.summary_totals.coverage_functions_found as f64
14097                        * 100.0
14098                )
14099            },
14100        );
14101    let cov_branch_pct_str: String = latest_run
14102        .as_ref()
14103        .filter(|r| r.summary_totals.coverage_branches_found > 0)
14104        .map_or_else(
14105            || "0".to_string(),
14106            |r| {
14107                format!(
14108                    "{:.1}",
14109                    r.summary_totals.coverage_branches_hit as f64
14110                        / r.summary_totals.coverage_branches_found as f64
14111                        * 100.0
14112                )
14113            },
14114        );
14115
14116    let cov_no_data_notice = if has_coverage {
14117        String::new()
14118    } else {
14119        String::from(
14120            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
14121<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>
14122<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
14123  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
14124  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
14125  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14126  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
14127  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14128  <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>
14129  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14130  <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>
14131  <span style="color:var(--muted);font-size:12px;">&middot;</span>
14132  <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>
14133</div>
14134<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
14135</div>"#,
14136        )
14137    };
14138
14139    let workspace_density_str = format!("{workspace_density:.1}");
14140    let nonce = &csp_nonce;
14141    let toast_assets = sloc_toast_assets(nonce);
14142    let version = env!("CARGO_PKG_VERSION");
14143
14144    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
14145    // of interactive controls — folder watching is managed by the host administrator.
14146    let watched_dirs_html: String = if state.server_mode {
14147        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()
14148    } else {
14149        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
14150            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
14151                .to_string()
14152        } else {
14153            watched_dirs_list
14154                .iter()
14155                .fold(String::new(), |mut s, d| {
14156                    use std::fmt::Write as _;
14157                    let escaped =
14158                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
14159                    write!(
14160                        s,
14161                        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>"#
14162                    ).expect("write to String is infallible");
14163                    s
14164                })
14165        };
14166        format!(
14167            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>"#
14168        )
14169    };
14170
14171    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
14172    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
14173
14174    let html = format!(
14175        r#"<!doctype html>
14176<html lang="en">
14177<head>
14178  <meta charset="utf-8" />
14179  <meta name="viewport" content="width=device-width, initial-scale=1" />
14180  <title>OxideSLOC | Test Metrics</title>
14181  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14182  <style nonce="{nonce}">
14183    :root {{
14184      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
14185      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14186      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14187      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14188      --info-bg:#eef3ff; --info-text:#4467d8;
14189    }}
14190    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
14191    *{{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;}}
14192    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
14193    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
14194    .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;}}
14195    @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));}}}}
14196    .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);}}
14197    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
14198    .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));}}
14199    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
14200    .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;}}
14201    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
14202    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
14203    @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; }} }}
14204    .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;}}
14205    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
14206    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
14207    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
14208    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
14209    .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;}}
14210    .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;}}
14211    .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;}}
14212    .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;}}
14213    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
14214    .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);}}
14215    .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;}}
14216    .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;}}
14217    .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;}}
14218    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
14219    .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;}}
14220    .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);}}
14221    .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;}}
14222    .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;}}
14223    .tz-select:focus{{border-color:var(--oxide);}}
14224    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
14225    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
14226    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
14227    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
14228    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
14229    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
14230    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
14231    .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);}}
14232    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
14233    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
14234    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
14235    .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;}}
14236    .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;}}
14237    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14238    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14239    .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);}}
14240    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14241    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14242    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14243    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14244    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14245    .chart-canvas-wrap{{position:relative;height:280px;}}
14246    .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;}}
14247    .chart-no-data svg{{opacity:0.35;}}
14248    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14249    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14250    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14251    .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;}}
14252    .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;}}
14253    .data-table tr:last-child td{{border-bottom:none;}}
14254    .data-table tbody tr:hover td{{background:var(--surface-2);}}
14255    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14256    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14257    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14258    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14259    .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;}}
14260    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14261    .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);}}
14262    .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14263    .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14264    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14265    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14266    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14267    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14268    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14269    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14270    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14271    .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;}}
14272    .chart-select:focus{{border-color:var(--accent);}}
14273    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14274    .trend-canvas-wrap{{position:relative;height:260px;}}
14275    .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;}}
14276    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14277    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14278    .site-footer a{{color:var(--muted);}}
14279    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14280    .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;}}
14281    .btn:hover{{background:var(--surface-2);}}
14282    .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;}}
14283    .export-btn:hover{{background:var(--line);}}
14284    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14285    /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14286    .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14287    .scope-export{{margin-left:auto;}}
14288    body.pdf-mode .export-group{{display:none!important;}}
14289    @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14290    .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;}}
14291    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14292    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14293    .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;}}
14294    .scope-sel:focus{{border-color:var(--accent);}}
14295    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14296    .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;}}
14297    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14298    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14299    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14300    .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;}}
14301    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14302    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14303    .watched-chip-rm:hover{{color:var(--oxide);}}
14304    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14305    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14306    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14307    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14308    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14309    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14310    .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;}}
14311    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14312    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14313    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14314    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14315    .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;}}
14316    .cov-file-search:focus{{border-color:var(--accent);}}
14317    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14318    .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;}}
14319    body.dark-theme .cov-file-search{{background:var(--surface);}}
14320    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14321    .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;}}
14322    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14323    .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;}}
14324    .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);}}
14325    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14326    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14327    .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;}}
14328    .chart-modal-close:hover{{opacity:.7;}}
14329    body.dark-theme .chart-modal{{background:var(--surface);}}
14330  </style>
14331</head>
14332<body>
14333  <div class="background-watermarks" aria-hidden="true">
14334    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14335    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14336    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14337    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14338    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14339    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14340  </div>
14341  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14342  <div class="top-nav">
14343    <div class="top-nav-inner">
14344      <a class="brand" href="/">
14345        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14346        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14347      </a>
14348      <div class="nav-right">
14349        <a class="nav-pill" href="/">Home</a>
14350        <div class="nav-dropdown">
14351          <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>
14352          <div class="nav-dropdown-menu">
14353            <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>
14354          </div>
14355        </div>
14356        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14357        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14358        <div class="nav-dropdown">
14359          <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>
14360          <div class="nav-dropdown-menu">
14361            <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>
14362          </div>
14363        </div>
14364        <div class="server-status-wrap" id="server-status-wrap">
14365          <div class="nav-pill server-online-pill" id="server-status-pill">
14366            <span class="status-dot" id="status-dot"></span>
14367            <span id="server-status-label">Server</span>
14368            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14369          </div>
14370          <div class="server-status-tip">
14371            OxideSLOC is running — accessible on your network.
14372            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14373          </div>
14374        </div>
14375        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14376          <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>
14377        </button>
14378        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14379          <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>
14380          <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>
14381        </button>
14382      </div>
14383    </div>
14384  </div>
14385
14386  <div class="page">
14387    {watched_dirs_html}
14388    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14389      <div class="scan-overlay-card">
14390        <div class="scan-spinner"></div>
14391        <div class="scan-overlay-text">Scanning folder…</div>
14392        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14393      </div>
14394    </div>
14395    <style>
14396    .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);}}
14397    .scan-overlay.active{{display:flex;}}
14398    .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;}}
14399    .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;}}
14400    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14401    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14402    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14403    </style>
14404    <div class="scope-bar">
14405      <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>
14406      <span class="scope-label">Scope</span>
14407      <div class="scope-sel-wrap">
14408        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14409        <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);">
14410          <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>
14411          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14412        </div>
14413      </div>
14414      <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14415      <div class="export-group scope-export" id="tm-export-group">
14416        <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)">
14417          <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>
14418          Export Excel
14419        </button>
14420        <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14421          <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>
14422          Export PNG
14423        </button>
14424        <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14425          <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>
14426          Export PDF
14427        </button>
14428      </div>
14429    </div>
14430    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14431      <div class="stat-chip"><div class="stat-chip-val" id="chip-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>
14432      <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>
14433      <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>
14434      <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>
14435    </div>
14436    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14437      <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>
14438      <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>
14439      <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>
14440      <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>
14441    </div>
14442
14443    <div class="panel" id="viz-panel">
14444      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14445
14446      <div class="chart-box" style="margin-bottom:18px;">
14447        <div class="chart-box-header">
14448          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14449          <div style="display:flex;gap:8px;align-items:center;">
14450            <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>
14451            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14452          </div>
14453        </div>
14454        <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>
14455        <div class="trend-controls-bar">
14456          <label>Y Metric:
14457            <select class="chart-select" id="tm-trend-y">
14458              <option value="test_count" selected>Test Definitions</option>
14459              <option value="code_lines">Code Lines</option>
14460            </select>
14461          </label>
14462          <label>X Axis:
14463            <select class="chart-select" id="tm-trend-x">
14464              <option value="commit" selected>By Commit</option>
14465              <option value="time">By Time</option>
14466            </select>
14467          </label>
14468          <label id="tm-sub-label" style="display:none;">Submodule:
14469            <select class="chart-select" id="tm-trend-sub">
14470              <option value="">All (project total)</option>
14471            </select>
14472          </label>
14473          <label>Chart Size:
14474            <select class="chart-select" id="tm-trend-size">
14475              <option value="200">Compact</option>
14476              <option value="260" selected>Normal</option>
14477              <option value="360">Large</option>
14478            </select>
14479          </label>
14480        </div>
14481        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14482        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14483      </div>
14484
14485      <div class="chart-row">
14486        <div class="chart-box">
14487          <div class="chart-box-header">
14488            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14489            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14490          </div>
14491          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14492          <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>
14493        </div>
14494        <div class="chart-box">
14495          <div class="chart-box-header">
14496            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14497            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14498          </div>
14499          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14500          <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>
14501        </div>
14502      </div>
14503
14504      <div class="chart-row">
14505        <div class="chart-box">
14506          <div class="chart-box-header">
14507            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14508            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14509          </div>
14510          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14511          <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>
14512        </div>
14513        <div class="chart-box" id="suites-chart-box">
14514          <div class="chart-box-header">
14515            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14516            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14517          </div>
14518          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14519          <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>
14520        </div>
14521      </div>
14522
14523      <div class="chart-row">
14524        <div class="chart-box">
14525          <div class="chart-box-title">Test Files Breakdown</div>
14526          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14527          <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>
14528        </div>
14529        <div class="chart-box">
14530          <div class="chart-box-title">Test Composition</div>
14531          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14532          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14533          <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>
14534        </div>
14535      </div>
14536    </div>
14537
14538    <div class="panel">
14539      <h1>Test Metrics</h1>
14540      <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>
14541
14542      <div class="section-header">Language Breakdown</div>
14543      {cov_no_data_notice}
14544      <div style="overflow-x:auto;">
14545        <table class="data-table" id="lang-table">
14546          <thead><tr>
14547            <th>Language</th>
14548            <th class="num">Test Fns</th>
14549            <th class="num">Assertions</th>
14550            <th class="num">Suites</th>
14551            <th class="num">Code Lines</th>
14552            <th class="num">Files</th>
14553            <th class="num">Density / 1K</th>
14554            <th>Relative Density</th>
14555          </tr></thead>
14556          <tbody id="lang-tbody"></tbody>
14557        </table>
14558      </div>
14559    </div>
14560
14561    <div class="panel" id="cov-panel" style="display:none;">
14562      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14563      <div class="cov-gauge-row" id="cov-gauges">
14564        <div class="cov-gauge-card">
14565          <div class="cov-gauge-label">Line Coverage</div>
14566          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14567          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14568          <div class="cov-gauge-sub">Lines hit / instrumented</div>
14569          <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>
14570        </div>
14571        <div class="cov-gauge-card">
14572          <div class="cov-gauge-label">Function Coverage</div>
14573          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14574          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14575          <div class="cov-gauge-sub">Functions hit / found</div>
14576          <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>
14577        </div>
14578        <div class="cov-gauge-card">
14579          <div class="cov-gauge-label">Branch Coverage</div>
14580          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14581          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14582          <div class="cov-gauge-sub">Branches hit / found</div>
14583          <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>
14584        </div>
14585      </div>
14586      <div class="chart-row">
14587        <div class="chart-box">
14588          <div class="chart-box-title">Line Coverage % by Language</div>
14589          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14590        </div>
14591        <div class="chart-box">
14592          <div class="chart-box-title">Coverage Tier Distribution</div>
14593          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14594        </div>
14595      </div>
14596
14597      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14598      <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>
14599      <div class="cov-file-toolbar">
14600        <div class="cov-filter-tabs" id="cov-filter-tabs">
14601          <button class="cov-tab active" data-tier="all">All</button>
14602          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14603          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
14604          <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
14605          <button class="cov-tab" data-tier="high">High (≥80%)</button>
14606        </div>
14607        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14608      </div>
14609      <div style="overflow-x:auto;">
14610        <table class="data-table" id="cov-file-table">
14611          <thead><tr>
14612            <th>File</th>
14613            <th>Lang</th>
14614            <th class="num">Line %</th>
14615            <th class="num">Lines Hit / Found</th>
14616            <th class="num">Fn %</th>
14617            <th class="num">Fns Hit / Found</th>
14618          </tr></thead>
14619          <tbody id="cov-file-tbody"></tbody>
14620        </table>
14621      </div>
14622      <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>
14623      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14624    </div>
14625
14626  </div>
14627
14628  <footer class="site-footer">
14629    local code analysis - metrics, history and reports
14630    &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>
14631    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14632    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14633    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14634    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14635  </footer>
14636
14637  <script nonce="{nonce}">
14638  (function() {{
14639    // Theme
14640    var b = document.body;
14641    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14642    var tgl = document.getElementById('theme-toggle');
14643    if (tgl) tgl.addEventListener('click', function() {{
14644      var d = b.classList.toggle('dark-theme');
14645      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14646    }});
14647
14648    // Watermarks
14649    (function() {{
14650      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14651      if (!wms.length) return;
14652      var placed = [];
14653      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;}}
14654      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];}}
14655      var half=Math.floor(wms.length/2);
14656      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;}});
14657    }})();
14658
14659    // Code particles
14660    (function() {{
14661      var container = document.getElementById('code-particles');
14662      if (!container) return;
14663      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14664      for (var i = 0; i < 36; i++) {{
14665        (function(idx) {{
14666          var el = document.createElement('span');
14667          el.className = 'code-particle';
14668          el.textContent = snippets[idx % snippets.length];
14669          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14670          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14671          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14672          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';
14673          container.appendChild(el);
14674        }})(i);
14675      }}
14676    }})();
14677
14678    // Settings modal
14679    (function() {{
14680      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'}}];
14681      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);}});}}
14682      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14683      var btn=document.getElementById('settings-btn');if(!btn)return;
14684      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14685      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>';
14686      document.body.appendChild(m);
14687      var g=document.getElementById('scheme-grid');
14688      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);}});
14689      var cl=document.getElementById('settings-close');
14690      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');}});
14691      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14692      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14693    }})();
14694
14695    // Watched folder picker
14696    (function(){{
14697      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');}};
14698      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);
14699    }})();
14700    (function() {{
14701      var btn = document.getElementById('add-watched-btn');
14702      if (!btn) return;
14703      btn.addEventListener('click', function() {{
14704        fetch('/pick-directory?kind=reports')
14705          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14706          .then(function(data) {{
14707            if (!data.cancelled && data.selected_path) {{
14708              var form = document.createElement('form');
14709              form.method = 'POST';
14710              form.action = '/watched-dirs/add';
14711              var ri = document.createElement('input');
14712              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14713              var fi = document.createElement('input');
14714              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14715              form.appendChild(ri); form.appendChild(fi);
14716              document.body.appendChild(form);
14717              if (window.__scanOverlay) window.__scanOverlay();
14718              form.submit();
14719            }}
14720          }})
14721          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14722      }});
14723    }})();
14724  }})();
14725  </script>
14726
14727  <script src="/static/chart.js" nonce="{nonce}"></script>
14728  <script nonce="{nonce}">
14729  (function() {{
14730    var SCOPE_DATA = {scope_data_json};
14731    var currentRoot = '__all__';
14732    var currentSub  = '';
14733    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14734    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14735    var ALL_CHARTS = [];
14736    var currentLangTests = [];
14737    var currentTrendPts = [];
14738
14739    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();}}
14740    function fmtFull(n){{return Number(n).toLocaleString();}}
14741    function isDark(){{return document.body.classList.contains('dark-theme');}}
14742    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14743    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14744    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14745
14746    function makeDlPlugin(fmtFn, anchor) {{
14747      return {{
14748        afterDatasetsDraw: function(chart) {{
14749          var ctx = chart.ctx;
14750          var tc = txtClr();
14751          chart.data.datasets.forEach(function(ds, di) {{
14752            var meta = chart.getDatasetMeta(di);
14753            meta.data.forEach(function(el, idx) {{
14754              var label = fmtFn(ds.data[idx], di, idx);
14755              if (label == null || label === '') return;
14756              ctx.save();
14757              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14758              ctx.fillStyle = tc;
14759              if (anchor === 'top') {{
14760                ctx.textAlign = 'center';
14761                ctx.textBaseline = 'bottom';
14762                ctx.fillText(String(label), el.x, el.y - 5);
14763              }} else {{
14764                ctx.textAlign = 'left';
14765                ctx.textBaseline = 'middle';
14766                ctx.fillText(String(label), el.x + 5, el.y);
14767              }}
14768              ctx.restore();
14769            }});
14770          }});
14771        }}
14772      }};
14773    }}
14774
14775    // Cursor: pointer over chart data, default over empty chart area.
14776    function chartCursor(e, els) {{
14777      var t = e.native && e.native.target;
14778      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14779    }}
14780    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14781
14782    // ── Global bar hover emphasis ──────────────────────────────────────────────
14783    // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14784    // *other* bars does nothing when there is only one). Give every bar chart a
14785    // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14786    // animates via the fast active transition. Applied globally through a plugin so
14787    // it covers all current and future bar charts on the page.
14788    function tmLighten(c, amt) {{
14789      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14790        var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14791        r = Math.round(r + (255 - r) * amt);
14792        g = Math.round(g + (255 - g) * amt);
14793        b = Math.round(b + (255 - b) * amt);
14794        return 'rgb(' + r + ',' + g + ',' + b + ')';
14795      }}
14796      return c;
14797    }}
14798    var tmBarHoverEmphasis = {{
14799      id: 'tmBarHoverEmphasis',
14800      beforeInit: function(chart) {{
14801        if (!chart.config || chart.config.type !== 'bar') return;
14802        (chart.data.datasets || []).forEach(function(ds) {{
14803          var bg = ds.backgroundColor;
14804          if (ds.hoverBackgroundColor == null) {{
14805            ds.hoverBackgroundColor = Array.isArray(bg)
14806              ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14807              : tmLighten(bg, 0.24);
14808          }}
14809          if (ds.hoverBorderColor == null) {{
14810            ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14811          }}
14812          if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14813        }});
14814      }}
14815    }};
14816    Chart.register(tmBarHoverEmphasis);
14817    // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14818    try {{
14819      Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14820      Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14821      Chart.defaults.transitions.active.animation.duration = 260;
14822    }} catch (e) {{}}
14823
14824    // Plugin: draws % labels inside each doughnut slice.
14825    var donutPctPlugin = {{
14826      afterDatasetsDraw: function(chart) {{
14827        var ctx = chart.ctx;
14828        chart.data.datasets.forEach(function(ds, di) {{
14829          var meta = chart.getDatasetMeta(di);
14830          if (meta.hidden) return;
14831          var total = 0;
14832          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14833          if (!total) return;
14834          meta.data.forEach(function(arc, i) {{
14835            if (arc.hidden) return;
14836            var val = ds.data[i] || 0;
14837            var pct = val / total * 100;
14838            if (pct < 3) return;
14839            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14840            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14841            var tx = arc.x + midR * Math.cos(midAngle);
14842            var ty = arc.y + midR * Math.sin(midAngle);
14843            ctx.save();
14844            ctx.textAlign = 'center';
14845            ctx.textBaseline = 'middle';
14846            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14847            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14848            ctx.shadowBlur = 3;
14849            ctx.fillStyle = '#fff';
14850            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14851            ctx.restore();
14852          }});
14853        }});
14854      }}
14855    }};
14856
14857    function makeTmOverlay(title, subtitle, h) {{
14858      var overlay = document.createElement('div');
14859      overlay.className = 'chart-modal-overlay';
14860      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14861      var ch = Math.min(h || 560, maxH);
14862      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14863      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>';
14864      document.body.appendChild(overlay);
14865      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14866      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14867      return document.getElementById('tm-modal-canvas');
14868    }}
14869
14870    function getDataset() {{
14871      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14872      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14873      return r;
14874    }}
14875    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14876
14877    function showNoData(id, show) {{
14878      var el = document.getElementById(id);
14879      if (!el) return;
14880      var wrap = el.previousElementSibling;
14881      el.style.display = show ? '' : 'none';
14882      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14883    }}
14884
14885    // Shared hover treatment for every single-series bar/doughnut chart on this page:
14886    // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14887    // treatment used by the language charts on the scan results page.
14888    function tmFadeColor(c) {{
14889      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14890      return c;
14891    }}
14892    function tmApplyFade(chart, activeIdx) {{
14893      var ds = chart.data.datasets[0];
14894      if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14895      if (activeIdx == null) {{
14896        ds.backgroundColor = ds._baseBg.slice();
14897      }} else {{
14898        ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14899          return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14900        }});
14901      }}
14902    }}
14903    function tmFadeHover(e, active, chart) {{
14904      var t = e.native && e.native.target;
14905      if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14906      var idx = active.length ? active[0].index : null;
14907      if (chart._fadeIdx === idx) return;
14908      chart._fadeIdx = idx;
14909      tmApplyFade(chart, idx);
14910      // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14911      // transition (doughnuts keep their own hoverOffset motion regardless).
14912      chart.update('active');
14913    }}
14914    // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14915    function tmDoughnutLegendHover(e, item, leg) {{
14916      var ch = leg.chart;
14917      var t = e.native && e.native.target;
14918      if (t) t.style.cursor = 'pointer';
14919      ch._fadeIdx = item.index;
14920      ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14921      ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14922      tmApplyFade(ch, item.index);
14923      ch.update();
14924    }}
14925    function tmDoughnutLegendLeave(e, item, leg) {{
14926      var ch = leg.chart;
14927      var t = e.native && e.native.target;
14928      if (t) t.style.cursor = 'default';
14929      ch._fadeIdx = null;
14930      ch.setActiveElements([]);
14931      ch.tooltip.setActiveElements([], {{}});
14932      tmApplyFade(ch, null);
14933      ch.update('none');
14934    }}
14935
14936    function renderTestCharts(D) {{
14937      currentLangTests = D || [];
14938      testsChart = destroyChart(testsChart);
14939      densityChart = destroyChart(densityChart);
14940      if (!D || !D.length) {{
14941        showNoData('no-data-tests', true);
14942        showNoData('no-data-density', true);
14943        return;
14944      }}
14945      showNoData('no-data-tests', false);
14946      showNoData('no-data-density', false);
14947      var top15 = D.slice(0, 15);
14948      var canvas1 = document.getElementById('canvas-tests');
14949      if (canvas1) {{
14950        testsChart = new Chart(canvas1, {{
14951          type: 'bar',
14952          data: {{
14953            labels: top15.map(function(d){{ return d.lang; }}),
14954            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14955          }},
14956          options: {{
14957            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14958            layout: {{ padding: {{ right: 64 }} }},
14959            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14960            scales: {{
14961              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14962              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14963            }}
14964          }},
14965          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14966        }});
14967        ALL_CHARTS.push(testsChart);
14968      }}
14969      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14970      var canvas2 = document.getElementById('canvas-density');
14971      if (canvas2) {{
14972        densityChart = new Chart(canvas2, {{
14973          type: 'bar',
14974          data: {{
14975            labels: topD.map(function(d){{ return d.lang; }}),
14976            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 }}]
14977          }},
14978          options: {{
14979            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14980            layout: {{ padding: {{ right: 64 }} }},
14981            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14982            scales: {{
14983              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14984              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14985            }}
14986          }},
14987          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14988        }});
14989        ALL_CHARTS.push(densityChart);
14990      }}
14991    }}
14992
14993    function renderAssertionsChart(D) {{
14994      assertionsChart = destroyChart(assertionsChart);
14995      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14996      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14997      var canvas = document.getElementById('canvas-assertions');
14998      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14999      showNoData('no-data-assertions', false);
15000      assertionsChart = new Chart(canvas, {{
15001        type: 'bar',
15002        data: {{
15003          labels: top15.map(function(d){{ return d.lang; }}),
15004          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15005        }},
15006        options: {{
15007          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15008          layout: {{ padding: {{ right: 64 }} }},
15009          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15010          scales: {{
15011            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15012            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15013          }}
15014        }},
15015        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15016      }});
15017      ALL_CHARTS.push(assertionsChart);
15018    }}
15019
15020    function renderSuitesChart(D) {{
15021      suitesChart = destroyChart(suitesChart);
15022      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
15023      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15024      var canvas = document.getElementById('canvas-suites');
15025      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
15026      showNoData('no-data-suites', false);
15027      suitesChart = new Chart(canvas, {{
15028        type: 'bar',
15029        data: {{
15030          labels: top15.map(function(d){{ return d.lang; }}),
15031          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 }}]
15032        }},
15033        options: {{
15034          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15035          layout: {{ padding: {{ right: 64 }} }},
15036          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15037          scales: {{
15038            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
15039            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15040          }}
15041        }},
15042        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15043      }});
15044      ALL_CHARTS.push(suitesChart);
15045    }}
15046
15047    function renderFilesChart(totals) {{
15048      filesChart = destroyChart(filesChart);
15049      var canvas = document.getElementById('canvas-files');
15050      if (!canvas) return;
15051      var testF = totals.test_files || 0;
15052      var totalF = totals.total_files || 0;
15053      var nonTest = Math.max(0, totalF - testF);
15054      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
15055      showNoData('no-data-files', false);
15056      var dark = isDark();
15057      filesChart = new Chart(canvas, {{
15058        type: 'doughnut',
15059        data: {{
15060          labels: ['Test Files', 'Non-Test Files'],
15061          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15062        }},
15063        options: {{
15064          responsive: true, maintainAspectRatio: false, cutout: '62%',
15065          onHover: tmFadeHover,
15066          plugins: {{
15067            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
15068              generateLabels: function(chart) {{
15069                var ds = chart.data.datasets[0];
15070                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
15071                return chart.data.labels.map(function(lbl, i) {{
15072                  var val = ds.data[i] || 0;
15073                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
15074                  return {{
15075                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
15076                    fillStyle: ds.backgroundColor[i],
15077                    strokeStyle: ds.borderColor,
15078                    lineWidth: ds.borderWidth,
15079                    hidden: false,
15080                    index: i,
15081                    datasetIndex: 0
15082                  }};
15083                }});
15084              }}
15085            }},
15086              onHover: tmDoughnutLegendHover,
15087              onLeave: tmDoughnutLegendLeave
15088            }},
15089            tooltip: {{ callbacks: {{ label: function(ctx) {{
15090              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
15091              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
15092            }} }} }}
15093          }}
15094        }},
15095        plugins: [donutPctPlugin]
15096      }});
15097      ALL_CHARTS.push(filesChart);
15098    }}
15099
15100    function renderCompositionChart(totals) {{
15101      compositionChart = destroyChart(compositionChart);
15102      var canvas = document.getElementById('canvas-composition');
15103      if (!canvas) return;
15104      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
15105      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
15106      showNoData('no-data-composition', false);
15107      compositionChart = new Chart(canvas, {{
15108        type: 'bar',
15109        data: {{
15110          labels: ['Test Functions', 'Assertions', 'Test Suites'],
15111          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
15112        }},
15113        options: {{
15114          responsive: true, maintainAspectRatio: false,
15115          onHover: tmFadeHover,
15116          layout: {{ padding: {{ top: 22 }} }},
15117          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
15118          scales: {{
15119            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
15120            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15121          }}
15122        }},
15123        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
15124      }});
15125      ALL_CHARTS.push(compositionChart);
15126    }}
15127
15128    function renderCovCharts(covD, tiers) {{
15129      covChart = destroyChart(covChart);
15130      tierChart = destroyChart(tierChart);
15131      var covCanvas = document.getElementById('canvas-cov');
15132      if (covCanvas && covD && covD.length) {{
15133        covChart = new Chart(covCanvas, {{
15134          type: 'bar',
15135          data: {{
15136            labels: covD.map(function(d){{ return d.lang; }}),
15137            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 }}]
15138          }},
15139          options: {{
15140            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15141            layout: {{ padding: {{ right: 52 }} }},
15142            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
15143            scales: {{
15144              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
15145              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
15146            }}
15147          }},
15148          plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
15149        }});
15150        ALL_CHARTS.push(covChart);
15151      }}
15152      var tierCanvas = document.getElementById('canvas-cov-tiers');
15153      if (tierCanvas && tiers) {{
15154        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
15155        tierChart = new Chart(tierCanvas, {{
15156          type: 'doughnut',
15157          data: {{
15158            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
15159            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
15160          }},
15161          options: {{
15162            responsive: true, maintainAspectRatio: false, cutout: '62%',
15163            onHover: tmFadeHover,
15164            plugins: {{
15165              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
15166                onHover: tmDoughnutLegendHover,
15167                onLeave: tmDoughnutLegendLeave
15168              }},
15169              tooltip: {{ callbacks: {{ label: function(ctx) {{
15170                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
15171                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
15172              }} }} }}
15173            }}
15174          }},
15175          plugins: [donutPctPlugin]
15176        }});
15177        ALL_CHARTS.push(tierChart);
15178      }}
15179    }}
15180
15181    function buildLangTable(D) {{
15182      var tbody = document.getElementById('lang-tbody');
15183      if (!tbody) return;
15184      if (!D || !D.length) {{
15185        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>';
15186        return;
15187      }}
15188      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
15189      tbody.innerHTML = D.map(function(d) {{
15190        var barW = Math.round(d.density / maxDensity * 120);
15191        return '<tr>' +
15192          '<td><strong>' + d.lang + '</strong></td>' +
15193          '<td class="num">' + fmtFull(d.tests) + '</td>' +
15194          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
15195          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
15196          '<td class="num">' + fmtFull(d.code) + '</td>' +
15197          '<td class="num">' + fmtFull(d.files) + '</td>' +
15198          '<td class="num">' + d.density.toFixed(2) + '</td>' +
15199          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
15200          '</tr>';
15201      }}).join('');
15202    }}
15203
15204    var covFileData = [];
15205    var covFileTier = 'all';
15206    var covFileSearch = '';
15207
15208    function pctBadge(pct) {{
15209      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
15210      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
15211      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
15212    }}
15213
15214    function buildCovFileTable() {{
15215      var tbody = document.getElementById('cov-file-tbody');
15216      var empty = document.getElementById('cov-file-empty');
15217      var count = document.getElementById('cov-file-count');
15218      if (!tbody) return;
15219      var srch = covFileSearch.toLowerCase();
15220      var filtered = covFileData.filter(function(f) {{
15221        if (covFileTier === 'zero' && f.line_pct > 0) return false;
15222        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
15223        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
15224        if (covFileTier === 'high' && f.line_pct < 80) return false;
15225        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
15226        return true;
15227      }});
15228      if (!filtered.length) {{
15229        tbody.innerHTML = '';
15230        if (empty) empty.style.display = '';
15231        if (count) count.textContent = '';
15232        return;
15233      }}
15234      if (empty) empty.style.display = 'none';
15235      var shown = Math.min(filtered.length, 500);
15236      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15237      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15238        var fnCol = f.fn_pct < 0
15239          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15240          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15241        return '<tr>' +
15242          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
15243          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15244          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15245          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15246          fnCol +
15247          '</tr>';
15248      }}).join('');
15249    }}
15250
15251    (function() {{
15252      var tabs = document.getElementById('cov-filter-tabs');
15253      if (tabs) {{
15254        tabs.addEventListener('click', function(e) {{
15255          var btn = e.target.closest('.cov-tab');
15256          if (!btn) return;
15257          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15258          btn.classList.add('active');
15259          covFileTier = btn.getAttribute('data-tier');
15260          buildCovFileTable();
15261        }});
15262      }}
15263      var srch = document.getElementById('cov-file-search');
15264      if (srch) {{
15265        srch.addEventListener('input', function() {{
15266          covFileSearch = this.value;
15267          buildCovFileTable();
15268        }});
15269      }}
15270    }})();
15271
15272    function updateCovGauges(t) {{
15273      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15274      var el;
15275      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15276      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15277      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15278      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15279      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15280      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15281    }}
15282
15283    function applyScope() {{
15284      var d = getDataset();
15285      var t = d.totals;
15286      var el;
15287      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15288      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15289      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15290      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15291      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15292      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15293      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15294      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15295      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15296      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15297      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15298      renderTestCharts(d.lang_tests);
15299      renderAssertionsChart(d.lang_tests);
15300      renderSuitesChart(d.lang_tests);
15301      renderFilesChart(t);
15302      renderCompositionChart(t);
15303      buildLangTable(d.lang_tests);
15304      var covPanel = document.getElementById('cov-panel');
15305      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15306      if (d.has_coverage) {{
15307        renderCovCharts(d.cov, d.cov_tiers);
15308        updateCovGauges(t);
15309        covFileData = d.file_cov || [];
15310        covFileTier = 'all';
15311        covFileSearch = '';
15312        var tabs = document.getElementById('cov-filter-tabs');
15313        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15314        var srch = document.getElementById('cov-file-search');
15315        if (srch) srch.value = '';
15316        buildCovFileTable();
15317      }}
15318      loadTrend();
15319    }}
15320
15321    // Populate scope-root-sel from SCOPE_DATA keys
15322    (function() {{
15323      var sel = document.getElementById('scope-root-sel');
15324      if (!sel) return;
15325      Object.keys(SCOPE_DATA).forEach(function(k) {{
15326        if (k === '__all__') return;
15327        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15328      }});
15329    }})();
15330
15331    document.getElementById('scope-root-sel').addEventListener('change', function() {{
15332      currentRoot = this.value;
15333      currentSub = '';
15334      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15335      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15336      var subWrap = document.getElementById('scope-sub-wrap');
15337      var subSel  = document.getElementById('scope-sub-sel');
15338      subSel.innerHTML = '<option value="">Entire project</option>';
15339      if (subNames.length) {{
15340        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15341        subWrap.style.display = 'flex';
15342      }} else {{
15343        subWrap.style.display = 'none';
15344      }}
15345      applyScope();
15346    }});
15347
15348    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15349      currentSub = this.value;
15350      applyScope();
15351    }});
15352
15353    var allTrendData = [];
15354
15355    var TM_Y_META = {{
15356      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15357      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
15358    }};
15359
15360    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15361    function hexRgb(hex) {{
15362      var h = String(hex).replace('#', '');
15363      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15364      var n = parseInt(h, 16);
15365      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15366    }}
15367    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15368    // tint at the top to transparent at the bottom (no flat solid block).
15369    function tmTrendGradient(ctx2, chartArea, color) {{
15370      var rgb = hexRgb(color);
15371      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15372      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
15373      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15374      g.addColorStop(1,   'rgba(' + rgb + ',0)');
15375      return g;
15376    }}
15377
15378    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15379    // so linear interpolation between adjacent points matches the drawn line).
15380    function tmLineYAt(chart, px) {{
15381      var meta = chart.getDatasetMeta(0);
15382      if (!meta || !meta.data || !meta.data.length) return null;
15383      var d = meta.data;
15384      if (px <= d[0].x) return d[0].y;
15385      for (var i = 1; i < d.length; i++) {{
15386        if (px <= d[i].x) {{
15387          var span = d[i].x - d[i - 1].x;
15388          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15389          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15390        }}
15391      }}
15392      return d[d.length - 1].y;
15393    }}
15394
15395    // Plugin: only show the tooltip / finger cursor when the pointer is over the
15396    // gradient fill (inside the plot and at/below the line) — never in the empty
15397    // space above the line. Outside the fill we retype the event as 'mouseout' so
15398    // the core interaction dismisses any active tooltip on its own.
15399    var tmFillGuard = {{
15400      id: 'tmFillGuard',
15401      beforeEvent: function(chart, args) {{
15402        var e = args.event;
15403        if (!e || e.type !== 'mousemove') return;
15404        var ca = chart.chartArea;
15405        if (!ca) return;
15406        var inFill = false;
15407        if (e.x >= ca.left && e.x <= ca.right) {{
15408          var ly = tmLineYAt(chart, e.x);
15409          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15410        }}
15411        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15412        if (!inFill) {{ e.type = 'mouseout'; }}
15413      }}
15414    }};
15415
15416    // Single source of truth for the test-metrics trend chart config so the inline
15417    // chart and the Full View modal render identically (straight segments, gradient
15418    // fill, white-ringed points, gradient-only interactivity).
15419    function buildTmTrendConfig(pts, ctrl, meta) {{
15420      return {{
15421        type: 'line',
15422        data: {{
15423          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15424          datasets: [{{
15425            label: meta.label,
15426            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15427            borderColor: meta.color,
15428            borderWidth: 2.5,
15429            backgroundColor: function(context) {{
15430              var ca = context.chart.chartArea;
15431              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15432              return tmTrendGradient(context.chart.ctx, ca, meta.color);
15433            }},
15434            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15435            pointBorderColor: '#fff',
15436            pointBorderWidth: 2,
15437            pointRadius: 6,
15438            pointHoverRadius: 9,
15439            pointHoverBorderWidth: 2.5,
15440            fill: true, tension: 0
15441          }}]
15442        }},
15443        options: {{
15444          responsive: true, maintainAspectRatio: false,
15445          layout: {{ padding: {{ top: 22 }} }},
15446          interaction: {{ mode: 'index', intersect: false }},
15447          plugins: {{
15448            legend: {{ display: false }},
15449            tooltip: {{
15450              mode: 'index', intersect: false,
15451              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15452            }}
15453          }},
15454          scales: {{
15455            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15456            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15457          }}
15458        }},
15459        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15460      }};
15461    }}
15462
15463    function getTrendControls() {{
15464      var ySel    = document.getElementById('tm-trend-y');
15465      var xSel    = document.getElementById('tm-trend-x');
15466      var sizeSel = document.getElementById('tm-trend-size');
15467      var subSel  = document.getElementById('tm-trend-sub');
15468      return {{
15469        yKey:    ySel    ? ySel.value    : 'test_count',
15470        xMode:   xSel    ? xSel.value    : 'commit',
15471        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
15472        submod:  subSel  ? subSel.value  : ''
15473      }};
15474    }}
15475
15476    function makeTrendLabel(d, xMode) {{
15477      if (xMode === 'commit') {{
15478        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15479      }}
15480      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15481    }}
15482
15483    function buildTrend(data) {{
15484      allTrendData = data || [];
15485      renderTrend();
15486    }}
15487
15488    function renderTrend() {{
15489      var data = allTrendData;
15490      var ctrl = getTrendControls();
15491      var trendCanvas = document.getElementById('canvas-trend');
15492      var trendWrap   = document.getElementById('trend-canvas-wrap');
15493      var trendEmpty  = document.getElementById('trend-empty');
15494
15495      // Apply chart size
15496      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15497
15498      // Filter by submodule if selected (entries from project_label match)
15499      var pts = data.slice().reverse();
15500      if (ctrl.submod) {{
15501        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15502      }}
15503
15504      currentTrendPts = pts;
15505
15506      if (!pts.length) {{
15507        if (trendCanvas) trendCanvas.style.display = 'none';
15508        if (trendEmpty) trendEmpty.style.display = '';
15509        return;
15510      }}
15511      if (trendCanvas) trendCanvas.style.display = '';
15512      if (trendEmpty) trendEmpty.style.display = 'none';
15513
15514      trendChart = destroyChart(trendChart);
15515      if (!trendCanvas) return;
15516
15517      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15518
15519      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15520      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15521      ALL_CHARTS.push(trendChart);
15522
15523      // Populate submodule selector from unique project_labels
15524      var subSel = document.getElementById('tm-trend-sub');
15525      var subLabel = document.getElementById('tm-sub-label');
15526      if (subSel && data.length) {{
15527        var projects = [];
15528        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15529        if (projects.length > 1) {{
15530          var curVal = subSel.value;
15531          subSel.innerHTML = '<option value="">All (project total)</option>';
15532          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15533          if (subLabel) subLabel.style.display = '';
15534        }} else {{
15535          if (subLabel) subLabel.style.display = 'none';
15536        }}
15537      }}
15538    }}
15539
15540    // ── Full View expand buttons ──────────────────────────────────────────────
15541    (function() {{
15542      var btn = document.getElementById('tests-expand-btn');
15543      if (!btn) return;
15544      btn.addEventListener('click', function() {{
15545        var D = currentLangTests;
15546        if (!D || !D.length) return;
15547        var top15 = D.slice(0, 15);
15548        var h = Math.max(320, top15.length * 36 + 80);
15549        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15550        if (!canvas) return;
15551        new Chart(canvas, {{
15552          type: 'bar',
15553          data: {{
15554            labels: top15.map(function(d){{ return d.lang; }}),
15555            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15556          }},
15557          options: {{
15558            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15559            layout: {{ padding: {{ right: 72 }} }},
15560            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15561            scales: {{
15562              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15563              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15564            }}
15565          }},
15566          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15567        }});
15568      }});
15569    }})();
15570
15571    (function() {{
15572      var btn = document.getElementById('density-expand-btn');
15573      if (!btn) return;
15574      btn.addEventListener('click', function() {{
15575        var D = currentLangTests;
15576        if (!D || !D.length) return;
15577        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15578        var h = Math.max(320, topD.length * 36 + 80);
15579        var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15580        if (!canvas) return;
15581        new Chart(canvas, {{
15582          type: 'bar',
15583          data: {{
15584            labels: topD.map(function(d){{ return d.lang; }}),
15585            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 }}]
15586          }},
15587          options: {{
15588            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15589            layout: {{ padding: {{ right: 72 }} }},
15590            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15591            scales: {{
15592              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15593              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15594            }}
15595          }},
15596          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15597        }});
15598      }});
15599    }})();
15600
15601    (function() {{
15602      var btn = document.getElementById('trend-expand-btn');
15603      if (!btn) return;
15604      btn.addEventListener('click', function() {{
15605        var pts = currentTrendPts;
15606        if (!pts || !pts.length) return;
15607        var ctrl = getTrendControls();
15608        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15609        var title = meta.label + ' Trend \u2014 Full View';
15610        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15611        if (!canvas) return;
15612        // Reuse the exact inline-chart config so Full View matches the default view
15613        // (straight segments + gradient-only interactivity), just larger.
15614        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15615      }});
15616    }})();
15617
15618    (function() {{
15619      var btn = document.getElementById('assertions-expand-btn');
15620      if (!btn) return;
15621      btn.addEventListener('click', function() {{
15622        var D = currentLangTests;
15623        if (!D || !D.length) return;
15624        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15625        if (!top15.length) return;
15626        var h = Math.max(320, top15.length * 36 + 80);
15627        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15628        if (!canvas) return;
15629        new Chart(canvas, {{
15630          type: 'bar',
15631          data: {{
15632            labels: top15.map(function(d){{ return d.lang; }}),
15633            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15634          }},
15635          options: {{
15636            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15637            layout: {{ padding: {{ right: 72 }} }},
15638            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15639            scales: {{
15640              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15641              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15642            }}
15643          }},
15644          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15645        }});
15646      }});
15647    }})();
15648
15649    (function() {{
15650      var btn = document.getElementById('suites-expand-btn');
15651      if (!btn) return;
15652      btn.addEventListener('click', function() {{
15653        var D = currentLangTests;
15654        if (!D || !D.length) return;
15655        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15656        if (!top15.length) return;
15657        var h = Math.max(320, top15.length * 36 + 80);
15658        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15659        if (!canvas) return;
15660        new Chart(canvas, {{
15661          type: 'bar',
15662          data: {{
15663            labels: top15.map(function(d){{ return d.lang; }}),
15664            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 }}]
15665          }},
15666          options: {{
15667            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15668            layout: {{ padding: {{ right: 72 }} }},
15669            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15670            scales: {{
15671              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15672              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15673            }}
15674          }},
15675          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15676        }});
15677      }});
15678    }})();
15679
15680    // Wire trend control selectors — re-render without re-fetching
15681    (function() {{
15682      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15683        var el = document.getElementById(id);
15684        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15685      }});
15686    }})();
15687
15688    function loadTrend() {{
15689      var url = '/api/metrics/history?limit=100';
15690      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15691      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15692        buildTrend(data);
15693        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15694        var btn = document.getElementById('multi-compare-trend-btn');
15695        if (btn) {{
15696          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15697          if (ids.length >= 2) {{
15698            btn.style.display = '';
15699            btn.onclick = function() {{
15700              // Reverse so oldest first (API returns newest first).
15701              var sorted = ids.slice().reverse();
15702              if (sorted.length === 2) {{
15703                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15704              }} else {{
15705                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15706              }}
15707            }};
15708          }} else {{
15709            btn.style.display = 'none';
15710          }}
15711        }}
15712      }}).catch(function(){{
15713        var trendEmpty = document.getElementById('trend-empty');
15714        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15715      }});
15716    }}
15717
15718    // Re-render charts on theme toggle
15719    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15720      setTimeout(function() {{
15721        ALL_CHARTS.forEach(function(c) {{
15722          if (c && c.options && c.options.scales) {{
15723            Object.values(c.options.scales).forEach(function(ax) {{
15724              if (ax.grid) ax.grid.color = clr();
15725              if (ax.ticks) ax.ticks.color = txtClr();
15726            }});
15727            c.update();
15728          }}
15729        }});
15730      }}, 80);
15731    }});
15732
15733    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15734    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15735    function tmExportMeta() {{
15736      var sel = document.getElementById('scope-sel');
15737      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15738      if (!proj || proj === '__all__') proj = 'All projects';
15739      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15740      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15741      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15742      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15743      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15744    }}
15745
15746    function exportTmXLSX() {{
15747      var D = currentLangTests;
15748      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15749      var t = tmExportMeta();
15750      function s2b(s) {{ return new TextEncoder().encode(s); }}
15751      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
15752      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; }}
15753      function crc32(d) {{
15754        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;}}}}
15755        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15756      }}
15757      // Store all cells as strings so Excel left-aligns uniformly.
15758      function cs(addr, val, bold) {{
15759        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15760      }}
15761      // Build an Excel Table XML definition for a given sheet range and columns.
15762      function makeTableXml(tblId, name, ref, cols) {{
15763        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15764        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15765        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15766        x+='<autoFilter ref="'+ref+'"/>';
15767        x+='<tableColumns count="'+cols.length+'">';
15768        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15769        x+='</tableColumns>';
15770        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15771        return x+'</table>';
15772      }}
15773      // Worksheet XML with optional Excel Table part reference.
15774      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15775        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15776        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15777        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15778        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15779        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15780        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>';}});
15781        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>';}}
15782        x+='</sheetData>';
15783        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15784        return x+'</worksheet>';
15785      }}
15786
15787      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15788      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15789      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15790      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15791      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15792      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15793
15794      // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15795      // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15796      var sheets=[];
15797
15798      // Sheet: Summary
15799      var sumHdr=['Metric','Value'];
15800      var sumRows=[
15801        ['Project / Scope', t.proj],
15802        ['Export Date', t.full],
15803        ['Test Functions', Number(totTests).toLocaleString()],
15804        ['Assertions', Number(totAssert).toLocaleString()],
15805        ['Test Suites', Number(totSuites).toLocaleString()],
15806        ['Languages with Tests', String(D.length)],
15807        ['Total Code Lines', Number(totCode).toLocaleString()],
15808        ['Average Density (per 1K)', String(avgDensity)],
15809      ];
15810      sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15811
15812      // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15813      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15814      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)];}});
15815      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15816      sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15817
15818      // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15819      var covDs=(typeof getDataset==='function')?getDataset():null;
15820      if(covDs&&covDs.has_coverage){{
15821        var covT=covDs.totals||{{}};
15822        var covSumHdr=['Metric','Value'];
15823        var covSumRows=[
15824          ['Line Coverage', (covT.cov_line||'0')+'%'],
15825          ['Function Coverage', (covT.cov_fn||'0')+'%'],
15826          ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15827        ];
15828        if(covDs.cov_tiers){{
15829          covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15830          covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
15831          covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15832        }}
15833        sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15834
15835        if(covDs.cov&&covDs.cov.length){{
15836          var covLangHdr=['Language','Line Coverage %'];
15837          var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15838          sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15839        }}
15840        if(covFileData&&covFileData.length){{
15841          var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15842          var covFileRows=covFileData.map(function(f){{
15843            var noFn=f.fn_pct<0;
15844            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)];
15845          }});
15846          sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15847        }}
15848      }}
15849
15850      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>';
15851      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>';
15852
15853      // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15854      var files=[];
15855      var ctOverrides='', wbSheetTags='', wbRelTags='';
15856      sheets.forEach(function(sh,i){{
15857        var n=i+1;
15858        var lastCol=col2l(sh.hdr.length);
15859        var ref='A1:'+lastCol+(sh.rows.length+1);
15860        var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15861        var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15862        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>';
15863        files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15864        files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15865        files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15866        ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15867        ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15868        wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15869        wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15870      }});
15871      var styleRid='rId'+(sheets.length+1);
15872      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>';
15873      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>';
15874      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>';
15875      files.unshift(
15876        {{name:'[Content_Types].xml',data:s2b(ct)}},
15877        {{name:'_rels/.rels',data:s2b(dotrels)}},
15878        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15879        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15880        {{name:'xl/styles.xml',data:s2b(styl)}}
15881      );
15882      var parts=[],offsets=[],total=0;
15883      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;}});
15884      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;}});
15885      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));
15886      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;}});
15887      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15888      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15889      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15890      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15891    }}
15892
15893    function exportTmPNG() {{
15894      // Map canvas IDs to display titles
15895      var CHART_TITLES = {{
15896        'canvas-trend':       'TEST COUNT TREND',
15897        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15898        'canvas-density':     'TEST DENSITY (per 1,000 code lines)',
15899        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15900        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15901        'canvas-files':       'TEST FILES BREAKDOWN',
15902        'canvas-composition': 'TEST COMPOSITION',
15903        'canvas-cov':         'LINE COVERAGE % BY LANGUAGE',
15904        'canvas-cov-tiers':   'COVERAGE TIER DISTRIBUTION'
15905      }};
15906      // Coverage canvases are only appended when the LCOV panel is visible (has data).
15907      var covPanelEl=document.getElementById('cov-panel');
15908      var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15909      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15910      if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15911      // Include only charts that actually rendered data. A "no data" chart has its
15912      // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15913      // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15914      function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15915      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15916      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15917      var t=tmExportMeta();
15918      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15919      var trendCanvas=document.getElementById('canvas-trend');
15920      var hasTrend=chartHasData(trendCanvas);
15921      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15922      var TOTAL_W=COLW*2+GAP;
15923      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15924      TREND_H=Math.min(Math.max(200,TREND_H),340);
15925      // Per-row chart heights (2-col grid)
15926      var gridRows=Math.ceil(gridCanvases.length/2);
15927      var rowHeights=[];
15928      for(var ri=0;ri<gridRows;ri++){{
15929        var rh=240;
15930        for(var ci=0;ci<2;ci++){{
15931          var cv=gridCanvases[ri*2+ci];
15932          if(cv&&cv.width>0){{
15933            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15934            rh=Math.max(rh,Math.min(420,nat));
15935          }}
15936        }}
15937        rowHeights.push(rh);
15938      }}
15939      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15940      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15941      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15942      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15943      var ctx=out.getContext('2d');
15944      var cs2=getComputedStyle(document.body);
15945      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15946      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15947      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15948
15949      // Background
15950      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15951
15952      // Orange header block
15953      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15954      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15955      ctx.fillText('Test Metrics — '+t.proj,22,42);
15956      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15957      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15958      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15959
15960      // Helper: draw a section title label
15961      function drawTitle(label, x, y, w) {{
15962        ctx.save();
15963        ctx.fillStyle=oxide;
15964        ctx.font='700 11px '+TM_FONT;
15965        ctx.textBaseline='middle';
15966        ctx.textAlign='left';
15967        ctx.letterSpacing='0.07em';
15968        ctx.fillText(label, x+2, y+TITLE_H/2);
15969        // Underline
15970        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15971        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15972        ctx.globalAlpha=1;
15973        ctx.restore();
15974      }}
15975
15976      var yOff=HEADER_H;
15977
15978      // Trend chart (full width)
15979      if(hasTrend){{
15980        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15981        yOff+=TITLE_H;
15982        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15983        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15984        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15985        ctx.drawImage(surf,0,yOff);
15986        yOff+=TREND_H+ROW_PAD;
15987      }}
15988
15989      // Grid charts (2-col), each cell gets title + chart
15990      for(var gi=0;gi<gridRows;gi++){{
15991        var rh2=rowHeights[gi];
15992        // Draw row titles and charts
15993        for(var gci=0;gci<2;gci++){{
15994          var idx2=gi*2+gci;
15995          if(idx2>=gridCanvases.length)continue;
15996          var gcv=gridCanvases[idx2];
15997          var gx=gci*(COLW+GAP);
15998          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15999        }}
16000        yOff+=TITLE_H;
16001        for(var gci2=0;gci2<2;gci2++){{
16002          var idx3=gi*2+gci2;
16003          if(idx3>=gridCanvases.length)continue;
16004          var gcv2=gridCanvases[idx3];
16005          var gx2=gci2*(COLW+GAP);
16006          var natW=gcv2.width,natH=gcv2.height;
16007          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
16008          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
16009          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
16010          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
16011          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
16012          ctx.drawImage(surf2,gx2,yOff);
16013        }}
16014        yOff+=rh2+ROW_PAD;
16015      }}
16016
16017      // Dark footer
16018      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
16019      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
16020      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
16021
16022      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16023      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
16024    }}
16025
16026    function exportTmPDF(ev) {{
16027      var D=currentLangTests;
16028      var t=tmExportMeta();
16029      var strips=document.querySelectorAll('.summary-strip');
16030      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
16031      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
16032      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
16033      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
16034      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
16035      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
16036      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
16037      var rows='';
16038      (D||[]).forEach(function(d){{
16039        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
16040          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
16041          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
16042          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
16043          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
16044          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
16045          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
16046      }});
16047      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
16048        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
16049        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
16050        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
16051        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
16052        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
16053        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
16054      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>';
16055      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
16056        +'html,body{{height:100%;margin:0;}}'
16057        +'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;}}'
16058        +'.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;}}'
16059        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
16060        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
16061        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
16062        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
16063        +'.rep-body{{padding:20px 32px;flex:1;}}'
16064        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
16065        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
16066        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
16067        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
16068        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
16069        +'.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;}}'
16070        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
16071        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
16072        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
16073        +'.n{{text-align:right;}}'
16074        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
16075        +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
16076        +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
16077        +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
16078        +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
16079        +'.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;}}'
16080        +'</style>';
16081      // LCOV Coverage Summary section — only rendered when the current scope has coverage.
16082      var covDs=(typeof getDataset==='function')?getDataset():null;
16083      var covHtml='';
16084      if(covDs&&covDs.has_coverage){{
16085        var covT=covDs.totals||{{}};
16086        covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
16087          +'<div class="cov-strip">'
16088          +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
16089          +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
16090          +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
16091          +'</div>';
16092        if(covFileData&&covFileData.length){{
16093          var cfrows='';
16094          covFileData.forEach(function(f){{
16095            var noFn=f.fn_pct<0;
16096            cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
16097              +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
16098              +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
16099              +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
16100              +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
16101          }});
16102          covHtml+='<div class="section-hdr">Coverage File Detail</div>'
16103            +'<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>';
16104        }}
16105      }}
16106      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
16107        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
16108        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
16109        +'<div class="rep-body">'+statsHtml
16110        +'<div class="section-hdr">Language Breakdown</div>'
16111        +tableHtml+covHtml+'</div>'
16112        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
16113        +'</body></html>';
16114      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
16115      var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
16116      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
16117    }}
16118
16119    (function() {{
16120      // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
16121      // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
16122      var xBtn=document.getElementById('tm-export-xlsx-btn');
16123      var pngBtn=document.getElementById('tm-export-png-btn');
16124      var pdfBtn=document.getElementById('tm-export-pdf-btn');
16125      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
16126      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
16127      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
16128    }})();
16129
16130    applyScope();
16131  }})();
16132  </script>
16133  <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>
16134  {toast_assets}
16135</body>
16136</html>"#,
16137    );
16138    (
16139        [(axum::http::header::CACHE_CONTROL, "no-store")],
16140        Html(html),
16141    )
16142        .into_response()
16143}
16144
16145// ── Embeddable widget ─────────────────────────────────────────────────────────
16146// Protected. Returns a self-contained HTML page suitable for iframing inside
16147// Jenkins build summaries, Confluence iframe macros, or Jira panels.
16148//
16149// GET /embed/summary?run_id=<uuid>&theme=dark
16150
16151#[derive(Deserialize)]
16152struct EmbedQuery {
16153    run_id: Option<String>,
16154    theme: Option<String>,
16155}
16156
16157async fn embed_handler(
16158    State(state): State<AppState>,
16159    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
16160    Query(query): Query<EmbedQuery>,
16161) -> Response {
16162    let entry = {
16163        let reg = state.registry.lock().await;
16164        query.run_id.as_ref().map_or_else(
16165            || reg.entries.first().cloned(),
16166            |id| reg.find_by_run_id(id).cloned(),
16167        )
16168    };
16169
16170    let Some(entry) = entry else {
16171        return Html(
16172            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
16173                .to_string(),
16174        )
16175        .into_response();
16176    };
16177
16178    let dark = query.theme.as_deref() == Some("dark");
16179    let languages: Vec<(String, u64, u64)> = entry
16180        .json_path
16181        .as_ref()
16182        .and_then(|p| read_json(p).ok())
16183        .map(|run| {
16184            run.totals_by_language
16185                .iter()
16186                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
16187                .collect()
16188        })
16189        .unwrap_or_default();
16190
16191    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
16192}
16193
16194fn render_embed_widget(
16195    entry: &RegistryEntry,
16196    languages: &[(String, u64, u64)],
16197    dark: bool,
16198    csp_nonce: &str,
16199) -> String {
16200    let s = &entry.summary;
16201    let total = s.code_lines + s.comment_lines + s.blank_lines;
16202    let code_pct = s
16203        .code_lines
16204        .checked_mul(100)
16205        .and_then(|n| n.checked_div(total))
16206        .unwrap_or(0);
16207
16208    let (bg, fg, surface, muted, border) = if dark {
16209        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
16210    } else {
16211        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
16212    };
16213
16214    let mut lang_rows = String::new();
16215    for (name, files, code) in languages {
16216        write!(
16217            lang_rows,
16218            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
16219            escape_html(name),
16220            format_number(*files),
16221            format_number(*code),
16222        )
16223        .ok();
16224    }
16225
16226    let lang_table = if lang_rows.is_empty() {
16227        String::new()
16228    } else {
16229        format!(
16230            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
16231        )
16232    };
16233
16234    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
16235    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
16236    let project_esc = escape_html(&entry.project_label);
16237    let code_lines = format_number(s.code_lines);
16238    let comment_lines = format_number(s.comment_lines);
16239    let files = format_number(s.files_analyzed);
16240    let code_raw = s.code_lines;
16241    let comment_raw = s.comment_lines;
16242    let blank_raw = s.blank_lines;
16243
16244    format!(
16245        r#"<!doctype html>
16246<html lang="en">
16247<head>
16248  <meta charset="utf-8">
16249  <meta name="viewport" content="width=device-width,initial-scale=1">
16250  <title>OxideSLOC &mdash; {project_esc}</title>
16251  <script src="/static/chart.js"></script>
16252  <style nonce="{csp_nonce}">
16253    *{{box-sizing:border-box;margin:0;padding:0}}
16254    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16255    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16256    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16257    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16258    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16259    .card .v{{font-size:18px;font-weight:700}}
16260    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16261    .row{{display:flex;gap:12px;align-items:flex-start}}
16262    .pie{{width:120px;height:120px;flex-shrink:0}}
16263    .lt{{border-collapse:collapse;width:100%;flex:1}}
16264    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16265    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16266    .n{{text-align:right}}
16267    .footer{{margin-top:10px;color:{muted};font-size:10px}}
16268  </style>
16269</head>
16270<body>
16271  <h2>{project_esc}</h2>
16272  <div class="sub">{timestamp} &middot; run {run_short}</div>
16273  <div class="cards">
16274    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16275    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16276    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16277    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16278  </div>
16279  <div class="row">
16280    <canvas class="pie" id="c"></canvas>
16281    {lang_table}
16282  </div>
16283  <div class="footer">oxide-sloc</div>
16284  <script nonce="{csp_nonce}">
16285    new Chart(document.getElementById('c'),{{
16286      type:'doughnut',
16287      data:{{
16288        labels:['Code','Comments','Blank'],
16289        datasets:[{{
16290          data:[{code_raw},{comment_raw},{blank_raw}],
16291          backgroundColor:['#4a78ee','#b35428','#aaa'],
16292          borderWidth:0
16293        }}]
16294      }},
16295      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16296    }});
16297  </script>
16298</body>
16299</html>"#
16300    )
16301}
16302
16303/// Returns a process-wide mutex unique to `dir`, so that two requests writing
16304/// artifacts into the *same* output directory (e.g. re-ingesting an identical
16305/// `run_id`) serialize instead of corrupting each other's files. Directories that
16306/// differ never contend, so legitimate parallel analyses keep their throughput.
16307fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16308    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16309        OnceLock::new();
16310    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16311    let mut guard = map
16312        .lock()
16313        .unwrap_or_else(std::sync::PoisonError::into_inner);
16314    guard
16315        .entry(dir.to_path_buf())
16316        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16317        .clone()
16318}
16319
16320#[allow(clippy::too_many_lines)]
16321fn persist_run_artifacts(
16322    run: &sloc_core::AnalysisRun,
16323    report_html: &str,
16324    run_dir: &Path,
16325    report_title: &str,
16326    file_stem: &str,
16327    result_context: RunResultContext,
16328) -> Result<(RunArtifacts, PendingPdf)> {
16329    // Serialize concurrent writers targeting this same output directory so their
16330    // file writes cannot interleave and corrupt one another.
16331    let dir_lock = output_dir_lock(run_dir);
16332    let _dir_guard = dir_lock
16333        .lock()
16334        .unwrap_or_else(std::sync::PoisonError::into_inner);
16335
16336    // Root dir + organised subdirectories.
16337    let html_dir = run_dir.join("html");
16338    let pdf_dir = run_dir.join("pdf");
16339    let excel_dir = run_dir.join("excel");
16340    let json_dir = run_dir.join("json");
16341    let submodules_dir = run_dir.join("submodules");
16342    for dir in &[
16343        run_dir,
16344        &html_dir,
16345        &pdf_dir,
16346        &excel_dir,
16347        &json_dir,
16348        &submodules_dir,
16349    ] {
16350        fs::create_dir_all(dir)
16351            .with_context(|| format!("failed to create directory {}", dir.display()))?;
16352    }
16353
16354    // HTML report in html/.
16355    let html_path = {
16356        let path = html_dir.join(format!("report_{file_stem}.html"));
16357        fs::write(&path, report_html)
16358            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16359        Some(path)
16360    };
16361
16362    // JSON result in json/.
16363    let json_path = {
16364        let path = json_dir.join(format!("result_{file_stem}.json"));
16365        let json = serde_json::to_string_pretty(run)
16366            .context("failed to serialize analysis run to JSON")?;
16367        fs::write(&path, json)
16368            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16369        Some(path)
16370    };
16371
16372    // PDF in pdf/.
16373    let (pdf_path, pending_pdf) = {
16374        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16375        match write_pdf_from_run(run, &pdf_dest) {
16376            Ok(()) => {
16377                eprintln!(
16378                    "[oxide-sloc][pdf] native PDF written to {}",
16379                    pdf_dest.display()
16380                );
16381                (Some(pdf_dest), None)
16382            }
16383            Err(native_err) => {
16384                eprintln!(
16385                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16386                );
16387                let source_html_path = html_path
16388                    .as_ref()
16389                    .expect("html_path always Some here")
16390                    .clone();
16391                let pending = Some((source_html_path, pdf_dest.clone(), false));
16392                (Some(pdf_dest), pending)
16393            }
16394        }
16395    };
16396
16397    // CSV and XLSX in excel/.
16398    let csv_path = {
16399        let path = excel_dir.join(format!("report_{file_stem}.csv"));
16400        match sloc_report::write_csv(run, &path) {
16401            Err(e) => {
16402                eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16403                None
16404            }
16405            _ => Some(path),
16406        }
16407    };
16408
16409    let xlsx_path = {
16410        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16411        match sloc_report::write_xlsx(run, &path) {
16412            Err(e) => {
16413                eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16414                None
16415            }
16416            _ => Some(path),
16417        }
16418    };
16419
16420    // Scan config in json/.
16421    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16422
16423    // Eagerly generate sub-reports before index.html so relative links work.
16424    if run.effective_configuration.discovery.submodule_breakdown {
16425        let run_id = &run.tool.run_id;
16426        for s in &run.submodule_summaries {
16427            build_submodule_row(s, run, run_id, run_dir);
16428        }
16429    }
16430
16431    // index.html at root — offline static export of the result-page dashboard.
16432    generate_offline_index(
16433        run,
16434        run_dir,
16435        file_stem,
16436        html_path.as_deref(),
16437        pdf_path.as_deref(),
16438        json_path.as_deref(),
16439        scan_config_path.as_deref(),
16440        &result_context,
16441    );
16442
16443    Ok((
16444        RunArtifacts {
16445            output_dir: run_dir.to_path_buf(),
16446            html_path,
16447            pdf_path,
16448            json_path,
16449            csv_path,
16450            xlsx_path,
16451            scan_config_path,
16452            report_title: report_title.to_string(),
16453            result_context,
16454        },
16455        pending_pdf,
16456    ))
16457}
16458
16459/// Materialize a completed [`AnalysisRun`] into the exact on-disk layout the local web UI
16460/// produces, then register it in `<out_root>/registry.json` so the local Compare / "Scan
16461/// Delta" page can pair it with other runs.
16462///
16463/// This is the shared entry point used by both the web scan flow (indirectly, via
16464/// [`persist_run_artifacts`]) and the `oxide-sloc bundle` CLI command, so the run-directory
16465/// layout and registry schema can never drift between the two.
16466///
16467/// Layout produced under `<out_root>/<project_label>_<run_id>/`:
16468/// - `index.html`                          — offline dashboard
16469/// - `html/report_<stem>.html`             — full HTML report
16470/// - `json/result_<stem>.json`             — the serialized `AnalysisRun`
16471/// - `json/scan-config_<stem>.json`        — scan configuration snapshot
16472/// - `pdf/report_<stem>.pdf`               — best-effort native PDF (skipped on failure)
16473/// - `excel/report_<stem>.csv` / `.xlsx`   — tabular exports
16474/// - `submodules/`                         — per-submodule sub-reports (when enabled)
16475///
16476/// `<stem>` is `<project_label>_<git_commit_short>` when a commit is known, else
16477/// `<project_label>`. Returns the created run-directory path.
16478///
16479/// # Errors
16480///
16481/// Returns an error if the HTML report cannot be rendered or the artifacts cannot be written.
16482pub fn bundle_run(
16483    run: &AnalysisRun,
16484    out_root: &Path,
16485    run_id: &str,
16486    label: Option<&str>,
16487) -> Result<PathBuf> {
16488    // Project label: caller override, else derived exactly as the web UI derives it from the
16489    // first input root (falling back to a generic slug when there are no roots).
16490    let project_label = match label.map(str::trim).filter(|s| !s.is_empty()) {
16491        Some(explicit) => sanitize_project_label(explicit),
16492        None => {
16493            let fallback = run.input_roots.first().map_or("", String::as_str);
16494            derive_project_label(None, None, fallback)
16495        }
16496    };
16497
16498    let run_dir = out_root.join(format!("{project_label}_{run_id}"));
16499    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
16500
16501    let report_html = render_html(run).context("failed to render HTML report for bundle output")?;
16502
16503    let project_path = run.input_roots.first().cloned().unwrap_or_default();
16504    let result_context = RunResultContext {
16505        prev_entry: None,
16506        prev_scan_count: 0,
16507        project_path: project_path.clone(),
16508        cocomo_mode: "organic".to_string(),
16509        complexity_alert: 0,
16510        exclude_duplicates: false,
16511    };
16512
16513    let (artifacts, _pending_pdf) = persist_run_artifacts(
16514        run,
16515        &report_html,
16516        &run_dir,
16517        &run.effective_configuration.reporting.report_title,
16518        &file_stem,
16519        result_context,
16520    )?;
16521
16522    // Write the scan-config snapshot into json/ (same file the web flow writes).
16523    if let Some(ref cfg_path) = artifacts.scan_config_path {
16524        save_scan_config_json(
16525            cfg_path,
16526            run,
16527            &project_path,
16528            out_root.to_str(),
16529            "organic",
16530            0,
16531            false,
16532        );
16533    }
16534
16535    // Register the run so the local Compare page can find and pair it.
16536    let registry_path = out_root.join("registry.json");
16537    let mut registry = ScanRegistry::load(&registry_path);
16538    let entry = build_run_registry_entry(run, run_id, &project_label, &artifacts);
16539    registry.add_entry(entry);
16540    registry
16541        .save(&registry_path)
16542        .with_context(|| format!("failed to write registry to {}", registry_path.display()))?;
16543
16544    Ok(run_dir)
16545}
16546
16547/// Render a static offline result-page dashboard and write it as `index.html` at
16548/// the root of the run output directory so business users can open it from disk.
16549#[allow(clippy::too_many_arguments)]
16550#[allow(clippy::too_many_lines)]
16551#[allow(clippy::similar_names)]
16552fn generate_offline_index(
16553    run: &sloc_core::AnalysisRun,
16554    run_dir: &Path,
16555    file_stem: &str,
16556    html_path: Option<&Path>,
16557    pdf_path: Option<&Path>,
16558    json_path: Option<&Path>,
16559    scan_config_path: Option<&Path>,
16560    result_context: &RunResultContext,
16561) {
16562    let prev_entry = &result_context.prev_entry;
16563    let prev_scan_count = result_context.prev_scan_count;
16564    let project_path = &result_context.project_path;
16565
16566    let scan_delta = prev_entry.as_ref().and_then(|prev| {
16567        prev.json_path
16568            .as_ref()
16569            .and_then(|p| read_json(p).ok())
16570            .map(|prev_run| compute_delta(&prev_run, run))
16571    });
16572
16573    let files_analyzed = run.per_file_records.len() as u64;
16574    let files_skipped = run.skipped_file_records.len() as u64;
16575    let totals = sum_lang_totals(run);
16576
16577    let DeltaFields {
16578        prev_fa_str,
16579        prev_fs_str,
16580        prev_pl_str,
16581        prev_cl_str,
16582        prev_cml_str,
16583        prev_bl_str,
16584        delta_fa_str,
16585        delta_fa_class,
16586        delta_fs_str,
16587        delta_fs_class,
16588        delta_pl_str,
16589        delta_pl_class,
16590        delta_cl_str,
16591        delta_cl_class,
16592        delta_cml_str,
16593        delta_cml_class,
16594        delta_bl_str,
16595        delta_bl_class,
16596        delta_lines_added,
16597        delta_lines_removed,
16598        delta_lines_net_str,
16599        delta_lines_net_class,
16600    } = compute_delta_fields(
16601        prev_entry.as_ref(),
16602        &totals,
16603        files_analyzed,
16604        files_skipped,
16605        scan_delta.as_ref(),
16606    );
16607
16608    let git_commit_url = git_commit_url_for(run);
16609    let git_branch_url = git_branch_url_for(run);
16610    let scan_performed_by = scan_performed_by(run);
16611
16612    // Convert absolute path to relative from run_dir (for file:// navigation).
16613    let make_rel = |p: Option<&Path>| -> Option<String> {
16614        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16615            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16616    };
16617
16618    let run_id = &run.tool.run_id;
16619
16620    // Submodule rows with relative paths into submodules/.
16621    let submodule_rows: Vec<SubmoduleRow> = run
16622        .submodule_summaries
16623        .iter()
16624        .map(|s| {
16625            let safe = sanitize_project_label(&s.name);
16626            let key = format!("sub_{safe}");
16627            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16628            SubmoduleRow {
16629                name: s.name.clone(),
16630                relative_path: s.relative_path.clone(),
16631                files_analyzed: s.files_analyzed,
16632                code_lines: s.code_lines,
16633                comment_lines: s.comment_lines,
16634                blank_lines: s.blank_lines,
16635                total_physical_lines: s.total_physical_lines,
16636                html_url: if sub_path.exists() {
16637                    Some(format!("submodules/{key}.html"))
16638                } else {
16639                    None
16640                },
16641            }
16642        })
16643        .collect();
16644
16645    let lang_chart_json = build_lang_chart_json(run);
16646
16647    let scan_config_rel =
16648        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16649
16650    let template = ResultTemplate {
16651        version: env!("CARGO_PKG_VERSION"),
16652        report_title: run.effective_configuration.reporting.report_title.clone(),
16653        project_path: project_path.clone(),
16654        output_dir: display_path(run_dir),
16655        run_id: run_id.clone(),
16656        run_id_short: run_id
16657            .split('-')
16658            .next_back()
16659            .unwrap_or(run_id)
16660            .chars()
16661            .take(7)
16662            .collect(),
16663        files_analyzed,
16664        files_skipped,
16665        physical_lines: totals.physical_lines,
16666        code_lines: totals.code_lines,
16667        comment_lines: totals.comment_lines,
16668        blank_lines: totals.blank_lines,
16669        mixed_lines: totals.mixed_lines,
16670        functions: totals.functions,
16671        classes: totals.classes,
16672        variables: totals.variables,
16673        imports: totals.imports,
16674        html_url: make_rel(html_path),
16675        pdf_url: make_rel(pdf_path),
16676        json_url: make_rel(json_path),
16677        html_download_url: make_rel(html_path),
16678        pdf_download_url: make_rel(pdf_path),
16679        json_download_url: make_rel(json_path),
16680        html_path: html_path.map(display_path),
16681        json_path: json_path.map(display_path),
16682        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16683        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16684        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16685        prev_fa_str,
16686        prev_fs_str,
16687        prev_pl_str,
16688        prev_cl_str,
16689        prev_cml_str,
16690        prev_bl_str,
16691        delta_fa_str,
16692        delta_fa_class,
16693        delta_fs_str,
16694        delta_fs_class,
16695        delta_pl_str,
16696        delta_pl_class,
16697        delta_cl_str,
16698        delta_cl_class,
16699        delta_cml_str,
16700        delta_cml_class,
16701        delta_bl_str,
16702        delta_bl_class,
16703        delta_lines_added,
16704        delta_lines_removed,
16705        delta_lines_net_str,
16706        delta_lines_net_class,
16707        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16708        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16709        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16710        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16711        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16712        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16713        git_branch: run.git_branch.clone(),
16714        git_branch_url,
16715        git_commit: run.git_commit_short.clone(),
16716        git_commit_long: run.git_commit_long.clone(),
16717        git_author: run.git_commit_author.clone(),
16718        git_commit_url,
16719        scan_performed_by,
16720        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16721        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16722        os_display: format!(
16723            "{} / {}",
16724            run.environment.operating_system, run.environment.architecture
16725        ),
16726        test_count: run.summary_totals.test_count,
16727        test_assertion_count: run.summary_totals.test_assertion_count,
16728        current_scan_number: prev_scan_count + 1,
16729        prev_scan_count,
16730        submodule_rows,
16731        pdf_generating: false,
16732        scan_config_url: scan_config_rel,
16733        lang_chart_json,
16734        scatter_chart_json: build_scatter_chart_json(run),
16735        semantic_chart_json: build_semantic_chart_json(run),
16736        submodule_chart_json: build_submodule_chart_json(run),
16737        has_submodule_data: !run.submodule_summaries.is_empty(),
16738        has_semantic_data: run
16739            .totals_by_language
16740            .iter()
16741            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16742        csp_nonce: String::new(),
16743        confluence_configured: false,
16744        server_mode: false,
16745        report_header_footer: run
16746            .effective_configuration
16747            .reporting
16748            .report_header_footer
16749            .clone(),
16750        is_offline: true,
16751        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16752        lsloc: run.summary_totals.lsloc,
16753        uloc: run.uloc,
16754        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16755        duplicate_group_count: run.duplicate_groups.len(),
16756        has_cocomo: run.cocomo.is_some(),
16757        cocomo_effort_str: run
16758            .cocomo
16759            .as_ref()
16760            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16761        cocomo_duration_str: run
16762            .cocomo
16763            .as_ref()
16764            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16765        cocomo_staff_str: run
16766            .cocomo
16767            .as_ref()
16768            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16769        cocomo_ksloc_str: run
16770            .cocomo
16771            .as_ref()
16772            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16773        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16774            || "Organic".to_string(),
16775            |c| cocomo_mode_label(c.mode).to_string(),
16776        ),
16777        cocomo_mode_tooltip: run
16778            .cocomo
16779            .as_ref()
16780            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16781        complexity_alert: 0,
16782        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16783        cov_line_pct: cov_pct_str(
16784            run.summary_totals.coverage_lines_hit,
16785            run.summary_totals.coverage_lines_found,
16786        ),
16787        cov_fn_pct: cov_pct_str(
16788            run.summary_totals.coverage_functions_hit,
16789            run.summary_totals.coverage_functions_found,
16790        ),
16791        cov_branch_pct: cov_pct_str(
16792            run.summary_totals.coverage_branches_hit,
16793            run.summary_totals.coverage_branches_found,
16794        ),
16795        cov_lines_summary: cov_lines_summary_str(
16796            run.summary_totals.coverage_lines_hit,
16797            run.summary_totals.coverage_lines_found,
16798        ),
16799    };
16800
16801    if let Ok(html) = template.render() {
16802        // Inline the brand + watermark logos as data URIs: a file:// page has no
16803        // server to resolve the /images/logo/* routes, so without this the top-left
16804        // logo and the repeated "Oxide" background watermark render as broken images.
16805        let html = inline_offline_logos(&html);
16806        let index_path = run_dir.join("index.html");
16807        if let Err(e) = fs::write(&index_path, html) {
16808            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16809        }
16810    }
16811}
16812
16813/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
16814/// offline `index.html` displays the brand logo and background watermark when
16815/// opened directly from disk (file://), where the `/images/...` routes do not exist.
16816fn inline_offline_logos(html: &str) -> String {
16817    use base64::Engine;
16818    let text_uri = format!(
16819        "data:image/png;base64,{}",
16820        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16821    );
16822    let small_uri = format!(
16823        "data:image/png;base64,{}",
16824        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16825    );
16826    html.replace("/images/logo/logo-text.png", &text_uri)
16827        .replace("/images/logo/small-logo.png", &small_uri)
16828}
16829
16830/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
16831/// then root (old flat layout), for backwards compatibility.
16832fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16833    // New layout: json/scan-config_*.json
16834    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16835        return Some(found);
16836    }
16837    // Old flat layout: scan-config.json or scan-config_*.json at root
16838    find_scan_config_in_dir_flat(dir)
16839}
16840
16841fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16842    let exact = dir.join("scan-config.json");
16843    if exact.exists() {
16844        return Some(exact);
16845    }
16846    fs::read_dir(dir).ok().and_then(|entries| {
16847        entries
16848            .filter_map(std::result::Result::ok)
16849            .find(|e| {
16850                let name = e.file_name();
16851                let name = name.to_string_lossy();
16852                name.starts_with("scan-config") && name.ends_with(".json")
16853            })
16854            .map(|e| e.path())
16855    })
16856}
16857
16858// ── Config export / import ────────────────────────────────────────────────────
16859
16860/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
16861/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
16862#[derive(Deserialize)]
16863struct ExportPdfRequest {
16864    html: String,
16865    #[serde(default)]
16866    filename: Option<String>,
16867}
16868
16869async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16870    let html_content = body.html;
16871    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16872    if html_content.is_empty() {
16873        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16874    }
16875    // Write HTML to a temp file, run headless Chrome PDF export, read result.
16876    let tmp_dir = std::env::temp_dir();
16877    let html_path = tmp_dir.join(format!(
16878        "sloc-export-{}.html",
16879        uuid::Uuid::new_v4().simple()
16880    ));
16881    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16882    if let Err(e) = std::fs::write(&html_path, &html_content) {
16883        return (
16884            StatusCode::INTERNAL_SERVER_ERROR,
16885            format!("Failed to write temp HTML: {e}"),
16886        )
16887            .into_response();
16888    }
16889    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16890    let _ = std::fs::remove_file(&html_path);
16891    if let Err(e) = pdf_result {
16892        let _ = std::fs::remove_file(&pdf_path);
16893        return (
16894            StatusCode::INTERNAL_SERVER_ERROR,
16895            format!("PDF generation failed: {e}"),
16896        )
16897            .into_response();
16898    }
16899    let pdf_bytes = match std::fs::read(&pdf_path) {
16900        Ok(b) => b,
16901        Err(e) => {
16902            let _ = std::fs::remove_file(&pdf_path);
16903            return (
16904                StatusCode::INTERNAL_SERVER_ERROR,
16905                format!("Failed to read PDF: {e}"),
16906            )
16907                .into_response();
16908        }
16909    };
16910    let _ = std::fs::remove_file(&pdf_path);
16911    let safe_name: String = filename
16912        .chars()
16913        .map(|c| {
16914            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16915                c
16916            } else {
16917                '_'
16918            }
16919        })
16920        .collect();
16921    let disposition = format!("attachment; filename=\"{safe_name}\"");
16922    (
16923        [
16924            (header::CONTENT_TYPE, "application/pdf".to_string()),
16925            (header::CONTENT_DISPOSITION, disposition),
16926        ],
16927        pdf_bytes,
16928    )
16929        .into_response()
16930}
16931
16932async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16933    let toml_str = match toml::to_string_pretty(&state.base_config) {
16934        Ok(s) => s,
16935        Err(e) => {
16936            return (
16937                StatusCode::INTERNAL_SERVER_ERROR,
16938                format!("serialization error: {e}"),
16939            )
16940                .into_response();
16941        }
16942    };
16943    (
16944        [
16945            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16946            (
16947                header::CONTENT_DISPOSITION,
16948                "attachment; filename=\".oxide-sloc.toml\"",
16949            ),
16950        ],
16951        toml_str,
16952    )
16953        .into_response()
16954}
16955
16956#[derive(Serialize)]
16957struct OkResponse {
16958    ok: bool,
16959}
16960
16961#[derive(Serialize)]
16962struct SaveProfileResponse {
16963    ok: bool,
16964    id: String,
16965}
16966
16967#[derive(Serialize)]
16968struct ProfileListResponse {
16969    profiles: Vec<ScanProfile>,
16970}
16971
16972#[derive(Serialize)]
16973struct ImportConfigResponse {
16974    ok: bool,
16975    config: sloc_config::AppConfig,
16976}
16977
16978#[derive(Deserialize)]
16979struct ImportConfigBody {
16980    toml: String,
16981}
16982
16983async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16984    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16985        Ok(config) => {
16986            if let Err(e) = config.validate() {
16987                return error::unprocessable_entity(&e.to_string());
16988            }
16989            Json(ImportConfigResponse { ok: true, config }).into_response()
16990        }
16991        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16992    }
16993}
16994
16995// ── Scan profiles API ─────────────────────────────────────────────────────────
16996
16997async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16998    let store = state.scan_profiles.lock().await;
16999    Json(ProfileListResponse {
17000        profiles: store.profiles.clone(),
17001    })
17002}
17003
17004#[derive(Deserialize)]
17005struct SaveScanProfileBody {
17006    name: String,
17007    params: serde_json::Value,
17008}
17009
17010async fn api_save_scan_profile(
17011    State(state): State<AppState>,
17012    Json(body): Json<SaveScanProfileBody>,
17013) -> impl IntoResponse {
17014    if body.name.trim().is_empty() {
17015        return error::bad_request("name must not be empty");
17016    }
17017
17018    let id = uuid::Uuid::new_v4().to_string();
17019    let profile = ScanProfile {
17020        id: id.clone(),
17021        name: body.name.trim().to_string(),
17022        created_at: chrono::Utc::now().to_rfc3339(),
17023        params: body.params,
17024    };
17025
17026    let mut store = state.scan_profiles.lock().await;
17027    store.profiles.push(profile);
17028    if let Err(e) = store.save(&state.scan_profiles_path) {
17029        tracing::warn!("failed to persist scan profiles: {e}");
17030    }
17031    drop(store);
17032
17033    (
17034        StatusCode::CREATED,
17035        Json(SaveProfileResponse { ok: true, id }),
17036    )
17037        .into_response()
17038}
17039
17040async fn api_delete_scan_profile(
17041    State(state): State<AppState>,
17042    AxumPath(id): AxumPath<String>,
17043) -> impl IntoResponse {
17044    let mut store = state.scan_profiles.lock().await;
17045    let before = store.profiles.len();
17046    store.profiles.retain(|p| p.id != id);
17047    if store.profiles.len() == before {
17048        drop(store);
17049        return error::not_found("profile not found");
17050    }
17051    if let Err(e) = store.save(&state.scan_profiles_path) {
17052        tracing::warn!("failed to persist scan profiles: {e}");
17053    }
17054    drop(store);
17055    Json(OkResponse { ok: true }).into_response()
17056}
17057
17058fn resolve_output_root(raw: Option<&str>) -> PathBuf {
17059    let value = raw.unwrap_or("out/web").trim();
17060    let path = if value.is_empty() {
17061        PathBuf::from("out/web")
17062    } else {
17063        PathBuf::from(value)
17064    };
17065
17066    if path.is_absolute() {
17067        path
17068    } else {
17069        workspace_root().join(path)
17070    }
17071}
17072
17073/// Derive the directory that holds remote-repo clones from the output root.
17074fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
17075    std::env::var("SLOC_GIT_CLONES_DIR")
17076        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
17077}
17078
17079/// Build a deterministic filesystem path for a cloned remote repository.
17080/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
17081pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
17082    let safe: String = repo_url
17083        .chars()
17084        .map(|c| {
17085            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
17086                c
17087            } else {
17088                '_'
17089            }
17090        })
17091        .take(80)
17092        .collect();
17093    clones_dir.join(safe)
17094}
17095
17096/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
17097/// Runs synchronously — call from `tokio::task::spawn_blocking`.
17098pub(crate) fn scan_path_to_artifacts(
17099    scan_path: &Path,
17100    base_config: &AppConfig,
17101    label: &str,
17102) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
17103    let mut config = base_config.clone();
17104    config.discovery.root_paths = vec![scan_path.to_path_buf()];
17105    label.clone_into(&mut config.reporting.report_title);
17106    let run = analyze(&config, "git", None, None)?;
17107    let html = render_html(&run)?;
17108    let run_id = run.tool.run_id.clone();
17109    let project_label = sanitize_project_label(label);
17110    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
17111    let file_stem = {
17112        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
17113        if commit.is_empty() {
17114            project_label
17115        } else {
17116            format!("{project_label}_{commit}")
17117        }
17118    };
17119    let (artifacts, _pending_pdf) = persist_run_artifacts(
17120        &run,
17121        &html,
17122        &output_dir,
17123        label,
17124        &file_stem,
17125        RunResultContext::default(),
17126    )?;
17127    Ok((run_id, artifacts, run))
17128}
17129
17130/// Re-spawn background poll tasks for any polling schedules saved to disk.
17131async fn restart_poll_schedules(state: &AppState) {
17132    let store = state.schedules.lock().await;
17133    let poll_schedules: Vec<_> = store
17134        .schedules
17135        .iter()
17136        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
17137        .cloned()
17138        .collect();
17139    drop(store);
17140    for schedule in poll_schedules {
17141        let interval = schedule.interval_secs.unwrap_or(300);
17142        let st = state.clone();
17143        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
17144    }
17145}
17146
17147/// Warn at startup when GitLab webhook schedules exist but native TLS is not
17148/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
17149/// header (no HMAC over the body), so the token is exposed in cleartext unless
17150/// the transport is encrypted. This is only an advisory — TLS may be terminated
17151/// by an upstream reverse proxy, in which case the warning can be ignored.
17152async fn warn_insecure_gitlab_webhooks(state: &AppState) {
17153    if state.tls_enabled {
17154        return;
17155    }
17156    let store = state.schedules.lock().await;
17157    let has_gitlab_webhook = store.schedules.iter().any(|s| {
17158        s.kind == sloc_git::ScanScheduleKind::Webhook
17159            && s.provider == sloc_git::ScanScheduleProvider::GitLab
17160    });
17161    drop(store);
17162    if has_gitlab_webhook {
17163        tracing::warn!(
17164            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
17165             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
17166             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
17167             proxy so the token is not exposed in cleartext."
17168        );
17169    }
17170}
17171
17172fn split_patterns(raw: Option<&str>) -> Vec<String> {
17173    raw.unwrap_or("")
17174        .lines()
17175        .flat_map(|line| line.split(','))
17176        .map(str::trim)
17177        .filter(|part| !part.is_empty())
17178        .map(ToOwned::to_owned)
17179        .collect()
17180}
17181
17182#[must_use]
17183pub fn build_sub_run(
17184    parent: &AnalysisRun,
17185    sub: &sloc_core::SubmoduleSummary,
17186    parent_path: &str,
17187) -> AnalysisRun {
17188    let sub_files: Vec<_> = parent
17189        .per_file_records
17190        .iter()
17191        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
17192        .cloned()
17193        .collect();
17194    let mut config = parent.effective_configuration.clone();
17195    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
17196
17197    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
17198    let mut functions = 0u64;
17199    let mut classes = 0u64;
17200    let mut variables = 0u64;
17201    let mut imports = 0u64;
17202    let mut test_count = 0u64;
17203    let mut test_assertion_count = 0u64;
17204    let mut test_suite_count = 0u64;
17205    let mut mixed_lines_separate = 0u64;
17206    let mut coverage_lines_found = 0u64;
17207    let mut coverage_lines_hit = 0u64;
17208    let mut coverage_functions_found = 0u64;
17209    let mut coverage_functions_hit = 0u64;
17210    let mut coverage_branches_found = 0u64;
17211    let mut coverage_branches_hit = 0u64;
17212    for r in &sub_files {
17213        functions += r.raw_line_categories.functions;
17214        classes += r.raw_line_categories.classes;
17215        variables += r.raw_line_categories.variables;
17216        imports += r.raw_line_categories.imports;
17217        test_count += r.raw_line_categories.test_count;
17218        test_assertion_count += r.raw_line_categories.test_assertion_count;
17219        test_suite_count += r.raw_line_categories.test_suite_count;
17220        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
17221        if let Some(cov) = &r.coverage {
17222            coverage_lines_found += u64::from(cov.lines_found);
17223            coverage_lines_hit += u64::from(cov.lines_hit);
17224            coverage_functions_found += u64::from(cov.functions_found);
17225            coverage_functions_hit += u64::from(cov.functions_hit);
17226            coverage_branches_found += u64::from(cov.branches_found);
17227            coverage_branches_hit += u64::from(cov.branches_hit);
17228        }
17229    }
17230
17231    AnalysisRun {
17232        tool: parent.tool.clone(),
17233        environment: parent.environment.clone(),
17234        effective_configuration: config,
17235        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
17236        summary_totals: SummaryTotals {
17237            files_considered: sub.files_analyzed,
17238            files_analyzed: sub.files_analyzed,
17239            files_skipped: 0,
17240            total_physical_lines: sub.total_physical_lines,
17241            code_lines: sub.code_lines,
17242            comment_lines: sub.comment_lines,
17243            blank_lines: sub.blank_lines,
17244            mixed_lines_separate,
17245            functions,
17246            classes,
17247            variables,
17248            imports,
17249            test_count,
17250            test_assertion_count,
17251            test_suite_count,
17252            coverage_lines_found,
17253            coverage_lines_hit,
17254            coverage_functions_found,
17255            coverage_functions_hit,
17256            coverage_branches_found,
17257            coverage_branches_hit,
17258            cyclomatic_complexity: 0,
17259            lsloc: None,
17260            ..Default::default()
17261        },
17262        totals_by_language: sub.language_summaries.clone(),
17263        per_file_records: sub_files,
17264        skipped_file_records: vec![],
17265        warnings: vec![],
17266        submodule_summaries: vec![],
17267        git_commit_short: sub.git_commit_short.clone(),
17268        git_commit_long: sub.git_commit_long.clone(),
17269        git_branch: sub.git_branch.clone(),
17270        git_commit_author: sub.git_commit_author.clone(),
17271        git_commit_date: sub.git_commit_date.clone(),
17272        git_tags: None,
17273        git_nearest_tag: None,
17274        git_remote_url: sub.git_remote_url.clone(),
17275        style_summary: None,
17276        cocomo: None,
17277        uloc: 0,
17278        dryness_pct: None,
17279        duplicate_groups: vec![],
17280        duplicates_excluded: 0,
17281    }
17282}
17283
17284#[must_use]
17285pub fn sanitize_project_label(raw: &str) -> String {
17286    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
17287    // where `Path` treats '\' as a literal character, not a separator.
17288    let candidate = raw
17289        .split(['/', '\\'])
17290        .rfind(|s| !s.is_empty())
17291        .unwrap_or("project");
17292
17293    let mut value = String::with_capacity(candidate.len());
17294    for ch in candidate.chars() {
17295        if ch.is_ascii_alphanumeric() {
17296            value.push(ch.to_ascii_lowercase());
17297        } else {
17298            value.push('-');
17299        }
17300    }
17301
17302    let compact = value.trim_matches('-').to_string();
17303    if compact.is_empty() {
17304        "project".to_string()
17305    } else {
17306        compact
17307    }
17308}
17309
17310/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
17311/// comparisons with non-canonicalized stored paths work correctly.
17312fn strip_unc_prefix(path: PathBuf) -> PathBuf {
17313    let s = path.to_string_lossy();
17314    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17315        return PathBuf::from(format!(r"\\{rest}"));
17316    }
17317    if let Some(rest) = s.strip_prefix(r"\\?\") {
17318        return PathBuf::from(rest);
17319    }
17320    path
17321}
17322
17323/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
17324/// commit page URL for the most common hosting platforms.
17325fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
17326    let base = if let Some(rest) = remote.strip_prefix("git@") {
17327        let (host, path) = rest.split_once(':')?;
17328        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17329    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17330        remote
17331            .trim_end_matches('/')
17332            .trim_end_matches(".git")
17333            .to_owned()
17334    } else {
17335        return None;
17336    };
17337    let base = base.trim_end_matches('/');
17338    // GitLab uses /-/commit/; everything else uses /commit/
17339    if base.contains("gitlab.com") || base.contains("gitlab.") {
17340        Some(format!("{base}/-/commit/{sha}"))
17341    } else if base.contains("bitbucket.org") {
17342        Some(format!("{base}/commits/{sha}"))
17343    } else {
17344        Some(format!("{base}/commit/{sha}"))
17345    }
17346}
17347
17348/// Convert a git remote URL (https or git@) + branch name into a browser-openable
17349/// branch page URL for the most common hosting platforms.
17350fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17351    let base = if let Some(rest) = remote.strip_prefix("git@") {
17352        let (host, path) = rest.split_once(':')?;
17353        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17354    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17355        remote
17356            .trim_end_matches('/')
17357            .trim_end_matches(".git")
17358            .to_owned()
17359    } else {
17360        return None;
17361    };
17362    let base = base.trim_end_matches('/');
17363    if base.contains("gitlab.com") || base.contains("gitlab.") {
17364        Some(format!("{base}/-/tree/{branch}"))
17365    } else {
17366        Some(format!("{base}/tree/{branch}"))
17367    }
17368}
17369
17370fn display_path(path: &Path) -> String {
17371    let s = path.to_string_lossy();
17372    // Strip Windows extended-length prefix for display only; the underlying
17373    // PathBuf remains unchanged so file operations are unaffected.
17374    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
17375    // \\?\C:\path           →  C:\path          (local drive)
17376    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17377        return format!(r"\\{rest}");
17378    }
17379    if let Some(rest) = s.strip_prefix(r"\\?\") {
17380        return rest.to_owned();
17381    }
17382    s.into_owned()
17383}
17384
17385fn sanitize_path_str(s: &str) -> String {
17386    // Forward-slash variants of the Windows extended-length prefix that appear
17387    // when paths stored as plain strings have been processed through some path
17388    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
17389    if let Some(rest) = s.strip_prefix("//?/UNC/") {
17390        return format!("//{rest}");
17391    }
17392    if let Some(rest) = s.strip_prefix("//?/") {
17393        return rest.to_owned();
17394    }
17395    display_path(Path::new(s))
17396}
17397
17398fn workspace_root() -> PathBuf {
17399    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
17400    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17401        let p = PathBuf::from(root);
17402        if p.is_dir() {
17403            return p;
17404        }
17405    }
17406
17407    // Current working directory — works for `cargo run` from the project root
17408    // and for scripts/run.sh which cds there first.
17409    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17410}
17411
17412/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
17413fn make_git_label(repo: &str, ref_name: &str) -> String {
17414    if repo.is_empty() || ref_name.is_empty() {
17415        return String::new();
17416    }
17417    let base = repo
17418        .trim_end_matches('/')
17419        .trim_end_matches(".git")
17420        .rsplit('/')
17421        .next()
17422        .unwrap_or("repo");
17423    let ref_safe: String = ref_name
17424        .chars()
17425        .map(|c| {
17426            if c.is_alphanumeric() || c == '-' || c == '.' {
17427                c
17428            } else {
17429                '_'
17430            }
17431        })
17432        .collect();
17433    format!("{base}_at_{ref_safe}_sloc")
17434}
17435
17436/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
17437fn desktop_dir() -> PathBuf {
17438    if let Ok(profile) = std::env::var("USERPROFILE") {
17439        let p = PathBuf::from(profile).join("Desktop");
17440        if p.exists() {
17441            return p;
17442        }
17443    }
17444    if let Ok(home) = std::env::var("HOME") {
17445        let p = PathBuf::from(home).join("Desktop");
17446        if p.exists() {
17447            return p;
17448        }
17449    }
17450    workspace_root().join("out").join("web")
17451}
17452
17453fn resolve_input_path(raw: &str) -> PathBuf {
17454    let trimmed = raw.trim();
17455    if trimmed.is_empty() {
17456        return workspace_root().join("samples").join("basic");
17457    }
17458
17459    let candidate = PathBuf::from(trimmed);
17460    let resolved = if candidate.is_absolute() {
17461        candidate
17462    } else {
17463        let rooted = workspace_root().join(&candidate);
17464        if rooted.exists() {
17465            rooted
17466        } else {
17467            workspace_root().join(candidate)
17468        }
17469    };
17470
17471    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
17472    // strip that prefix so stored paths and the displayed "Project path" are clean.
17473    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17474    PathBuf::from(display_path(&canonical))
17475}
17476
17477fn dir_size_bytes(path: &Path) -> u64 {
17478    let mut total = 0u64;
17479    if let Ok(rd) = fs::read_dir(path) {
17480        for entry in rd.filter_map(Result::ok) {
17481            let p = entry.path();
17482            if p.is_file() {
17483                if let Ok(meta) = p.metadata() {
17484                    total += meta.len();
17485                }
17486            } else if p.is_dir() {
17487                total += dir_size_bytes(&p);
17488            }
17489        }
17490    }
17491    total
17492}
17493
17494#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
17495fn format_dir_size(bytes: u64) -> String {
17496    if bytes >= 1_073_741_824 {
17497        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17498    } else if bytes >= 1_048_576 {
17499        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17500    } else if bytes >= 1_024 {
17501        format!("{:.0} KB", bytes as f64 / 1_024.0)
17502    } else {
17503        format!("{bytes} B")
17504    }
17505}
17506
17507fn render_submodule_chips(
17508    root: &Path,
17509    submodules: &[(String, std::path::PathBuf)],
17510    out: &mut String,
17511) {
17512    use std::fmt::Write as _;
17513    let count = submodules.len();
17514    out.push_str(r#"<div class="submodule-preview-strip">"#);
17515    write!(
17516        out,
17517        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>"#,
17518        if count == 1 { "" } else { "s" }
17519    )
17520    .ok();
17521    out.push_str(r#"<div class="submodule-preview-chips">"#);
17522    for (sub_name, sub_rel_path) in submodules {
17523        let sub_abs = root.join(sub_rel_path);
17524        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17525        let mut sub_stats = PreviewStats::default();
17526        let mut sub_rows: Vec<PreviewRow> = Vec::new();
17527        let mut sub_langs: Vec<&'static str> = Vec::new();
17528        let mut sub_budget = PreviewBudget {
17529            shown: 0,
17530            max_entries: 2000,
17531            max_depth: 9,
17532        };
17533        let mut sub_next_id = 1usize;
17534        let _ = collect_preview_rows(
17535            &sub_abs,
17536            &sub_abs,
17537            0,
17538            None,
17539            &mut sub_next_id,
17540            &mut sub_budget,
17541            &mut sub_stats,
17542            &mut sub_rows,
17543            &mut sub_langs,
17544            &[],
17545            &[],
17546        );
17547        let stats_json = format!(
17548            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17549            sub_stats.directories,
17550            sub_stats.files,
17551            sub_stats.supported,
17552            sub_stats.skipped,
17553            sub_stats.unsupported
17554        );
17555        write!(
17556            out,
17557            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>"#,
17558            escape_html(sub_name),
17559            escape_html(&sub_rel_path.to_string_lossy()),
17560            escape_html(&sub_size),
17561            escape_html(&stats_json),
17562            escape_html(sub_name),
17563            escape_html(&sub_size),
17564        )
17565        .ok();
17566    }
17567    out.push_str(
17568        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
17569    );
17570    out.push_str(r"</div>");
17571}
17572
17573/// Amber caution banner shown when the selected folder spans multiple independent
17574/// git repositories. Each repo is a one-click button that re-selects it as the
17575/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
17576fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17577    use std::fmt::Write as _;
17578    const MAX_LISTED: usize = 5;
17579    let total = layout.nested_repos.len();
17580
17581    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17582    if layout.root_is_repo {
17583        write!(
17584            out,
17585            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>",
17586            if total == 1 { "repository" } else { "repositories" }
17587        )
17588        .ok();
17589    } else {
17590        write!(
17591            out,
17592            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>"
17593        )
17594        .ok();
17595    }
17596
17597    out.push_str(r#"<div class="repo-pick-row">"#);
17598    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17599        let abs = root.join(rel);
17600        let abs_display = display_path(&abs);
17601        let label = rel.to_string_lossy().replace('\\', "/");
17602        write!(
17603            out,
17604            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17605            escape_html(&abs_display),
17606            escape_html(&label)
17607        )
17608        .ok();
17609    }
17610    if total > MAX_LISTED {
17611        write!(
17612            out,
17613            r#"<span class="repo-pick-more">and {} more</span>"#,
17614            total - MAX_LISTED
17615        )
17616        .ok();
17617    }
17618    out.push_str(r"</div>");
17619
17620    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17621    out.push_str(r"</div>");
17622}
17623
17624fn render_language_pills_row(languages: &[&str], out: &mut String) {
17625    use std::fmt::Write as _;
17626    if languages.is_empty() {
17627        out.push_str(
17628            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17629        );
17630        return;
17631    }
17632    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17633    for language in languages {
17634        if let Some(icon) = language_icon_file(language) {
17635            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();
17636        } else if let Some(svg) = language_inline_svg(language) {
17637            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();
17638        } else {
17639            write!(
17640                out,
17641                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17642                escape_html(&language.to_ascii_lowercase()),
17643                escape_html(language)
17644            )
17645            .ok();
17646        }
17647    }
17648}
17649
17650#[allow(clippy::too_many_lines)]
17651fn build_preview_html(
17652    root: &Path,
17653    include_patterns: &[String],
17654    exclude_patterns: &[String],
17655) -> Result<String> {
17656    if !root.exists() {
17657        return Ok(format!(
17658            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17659            escape_html(&display_path(root))
17660        ));
17661    }
17662
17663    let _selected = display_path(root);
17664    let mut stats = PreviewStats::default();
17665    let mut rows = Vec::new();
17666    let mut languages = Vec::new();
17667    let mut budget = PreviewBudget {
17668        shown: 0,
17669        max_entries: 600,
17670        max_depth: 9,
17671    };
17672    let mut next_row_id = 1usize;
17673
17674    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17675        || root.to_string_lossy().into_owned(),
17676        std::string::ToString::to_string,
17677    );
17678    let root_modified = root
17679        .metadata()
17680        .ok()
17681        .and_then(|meta| meta.modified().ok())
17682        .map_or_else(|| "-".to_string(), format_system_time);
17683
17684    rows.push(PreviewRow {
17685        row_id: 0,
17686        parent_row_id: None,
17687        depth: 0,
17688        name: format!("{root_name}/"),
17689        kind: PreviewKind::Dir,
17690        is_dir: true,
17691        language: None,
17692        modified: root_modified,
17693        type_label: "Directory".to_string(),
17694    });
17695    collect_preview_rows(
17696        root,
17697        root,
17698        0,
17699        Some(0),
17700        &mut next_row_id,
17701        &mut budget,
17702        &mut stats,
17703        &mut rows,
17704        &mut languages,
17705        include_patterns,
17706        exclude_patterns,
17707    )?;
17708
17709    let root_size = format_dir_size(dir_size_bytes(root));
17710
17711    let mut out = String::new();
17712    write!(
17713        out,
17714        r#"<div class="explorer-wrap" data-project-size="{}">"#,
17715        escape_html(&root_size)
17716    )
17717    .ok();
17718    out.push_str(r#"<div class="explorer-toolbar compact">"#);
17719    out.push_str(r#"<div class="explorer-title-group">"#);
17720    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17721    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17722    out.push_str(r"</div></div>");
17723
17724    out.push_str(r#"<div class="scope-stats">"#);
17725    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();
17726    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();
17727    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();
17728    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();
17729    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();
17730    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>"#);
17731    out.push_str(r"</div>");
17732
17733    let submodules = sloc_core::detect_submodules(root);
17734    if !submodules.is_empty() {
17735        render_submodule_chips(root, &submodules, &mut out);
17736    }
17737
17738    let repo_layout = sloc_core::detect_repository_layout(root);
17739    if repo_layout.has_multiple_repos() {
17740        render_multi_repo_warning(root, &repo_layout, &mut out);
17741    }
17742
17743    out.push_str(r#"<div class="scope-info-row">"#);
17744    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17745    render_language_pills_row(&languages, &mut out);
17746    out.push_str(r"</div></div>");
17747    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>"#);
17748    out.push_str(r"</div>");
17749
17750    out.push_str(r#"<div class="file-explorer-shell">"#);
17751    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>"#);
17752    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>"#);
17753    out.push_str(r#"<div class="file-explorer-tree">"#);
17754    for row in rows {
17755        let status_label = row.kind.label();
17756        let lang_attr = row.language.unwrap_or("");
17757        let toggle_html = if row.is_dir {
17758            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17759                .to_string()
17760        } else {
17761            r#"<span class="tree-bullet">•</span>"#.to_string()
17762        };
17763        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();
17764    }
17765    if budget.shown >= budget.max_entries {
17766        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>"#);
17767    }
17768    out.push_str(r"</div></div></div>");
17769
17770    Ok(out)
17771}
17772
17773#[derive(Default)]
17774struct PreviewStats {
17775    directories: usize,
17776    files: usize,
17777    supported: usize,
17778    skipped: usize,
17779    unsupported: usize,
17780}
17781
17782struct PreviewRow {
17783    row_id: usize,
17784    parent_row_id: Option<usize>,
17785    depth: usize,
17786    name: String,
17787    kind: PreviewKind,
17788    is_dir: bool,
17789    language: Option<&'static str>,
17790    modified: String,
17791    type_label: String,
17792}
17793
17794#[derive(Copy, Clone)]
17795enum PreviewKind {
17796    Dir,
17797    Supported,
17798    Skipped,
17799    Unsupported,
17800}
17801
17802impl PreviewKind {
17803    const fn filter_key(self) -> &'static str {
17804        match self {
17805            Self::Dir => "dir",
17806            Self::Supported => "supported",
17807            Self::Skipped => "skipped",
17808            Self::Unsupported => "unsupported",
17809        }
17810    }
17811
17812    const fn label(self) -> &'static str {
17813        match self {
17814            Self::Dir => "dir",
17815            Self::Supported => "supported",
17816            Self::Skipped => "skipped by policy",
17817            Self::Unsupported => "unsupported",
17818        }
17819    }
17820
17821    const fn badge_class(self) -> &'static str {
17822        match self {
17823            Self::Dir => "badge badge-dir",
17824            Self::Supported => "badge badge-scan",
17825            Self::Skipped => "badge badge-skip",
17826            Self::Unsupported => "badge badge-unsupported",
17827        }
17828    }
17829
17830    const fn node_class(self) -> &'static str {
17831        match self {
17832            Self::Dir => "tree-node-dir",
17833            Self::Supported => "tree-node-supported",
17834            Self::Skipped => "tree-node-skipped",
17835            Self::Unsupported => "tree-node-unsupported",
17836        }
17837    }
17838}
17839
17840struct PreviewBudget {
17841    shown: usize,
17842    max_entries: usize,
17843    max_depth: usize,
17844}
17845
17846/// Handle a single directory entry inside `collect_preview_rows`.
17847/// Returns `true` when the entry was handled (caller should `continue`).
17848#[allow(clippy::too_many_arguments)]
17849fn handle_preview_dir_entry(
17850    root: &Path,
17851    path: &Path,
17852    name: &str,
17853    modified: String,
17854    depth: usize,
17855    parent_row_id: Option<usize>,
17856    row_id: usize,
17857    next_row_id: &mut usize,
17858    budget: &mut PreviewBudget,
17859    stats: &mut PreviewStats,
17860    rows: &mut Vec<PreviewRow>,
17861    languages: &mut Vec<&'static str>,
17862    include_patterns: &[String],
17863    exclude_patterns: &[String],
17864) -> Result<()> {
17865    let relative = preview_relative_path(root, path);
17866    if should_skip_preview_directory(&relative, exclude_patterns) {
17867        return Ok(());
17868    }
17869    stats.directories += 1;
17870    rows.push(PreviewRow {
17871        row_id,
17872        parent_row_id,
17873        depth: depth + 1,
17874        name: format!("{name}/"),
17875        kind: PreviewKind::Dir,
17876        is_dir: true,
17877        language: None,
17878        modified,
17879        type_label: "Directory".to_string(),
17880    });
17881    budget.shown += 1;
17882    if !matches!(name, ".git" | "node_modules" | "target") {
17883        collect_preview_rows(
17884            root,
17885            path,
17886            depth + 1,
17887            Some(row_id),
17888            next_row_id,
17889            budget,
17890            stats,
17891            rows,
17892            languages,
17893            include_patterns,
17894            exclude_patterns,
17895        )?;
17896    }
17897    Ok(())
17898}
17899
17900/// Handle a single file entry inside `collect_preview_rows`.
17901#[allow(clippy::too_many_arguments)]
17902fn handle_preview_file_entry(
17903    root: &Path,
17904    path: &Path,
17905    name: &str,
17906    modified: String,
17907    depth: usize,
17908    parent_row_id: Option<usize>,
17909    row_id: usize,
17910    budget: &mut PreviewBudget,
17911    stats: &mut PreviewStats,
17912    rows: &mut Vec<PreviewRow>,
17913    languages: &mut Vec<&'static str>,
17914    include_patterns: &[String],
17915    exclude_patterns: &[String],
17916) {
17917    let relative = preview_relative_path(root, path);
17918    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17919        return;
17920    }
17921    stats.files += 1;
17922    let kind = classify_preview_file(name);
17923    match kind {
17924        PreviewKind::Supported => stats.supported += 1,
17925        PreviewKind::Skipped => stats.skipped += 1,
17926        PreviewKind::Unsupported => stats.unsupported += 1,
17927        PreviewKind::Dir => {}
17928    }
17929    let language = detect_language_name(name);
17930    if let Some(lang) = language
17931        && !languages.contains(&lang)
17932    {
17933        languages.push(lang);
17934    }
17935    rows.push(PreviewRow {
17936        row_id,
17937        parent_row_id,
17938        depth: depth + 1,
17939        name: name.to_owned(),
17940        kind,
17941        is_dir: false,
17942        language,
17943        modified,
17944        type_label: preview_type_label(name, language, kind),
17945    });
17946    budget.shown += 1;
17947}
17948
17949#[allow(clippy::too_many_arguments)]
17950#[allow(clippy::too_many_lines)]
17951fn collect_preview_rows(
17952    root: &Path,
17953    dir: &Path,
17954    depth: usize,
17955    parent_row_id: Option<usize>,
17956    next_row_id: &mut usize,
17957    budget: &mut PreviewBudget,
17958    stats: &mut PreviewStats,
17959    rows: &mut Vec<PreviewRow>,
17960    languages: &mut Vec<&'static str>,
17961    include_patterns: &[String],
17962    exclude_patterns: &[String],
17963) -> Result<()> {
17964    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17965        return Ok(());
17966    }
17967
17968    let mut entries = fs::read_dir(dir)
17969        .with_context(|| format!("failed to read directory {}", dir.display()))?
17970        .filter_map(std::result::Result::ok)
17971        .collect::<Vec<_>>();
17972    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17973
17974    for entry in entries {
17975        if budget.shown >= budget.max_entries {
17976            break;
17977        }
17978
17979        let path = entry.path();
17980        let name = entry.file_name().to_string_lossy().into_owned();
17981        let Ok(metadata) = entry.metadata() else {
17982            continue;
17983        };
17984        let row_id = *next_row_id;
17985        *next_row_id += 1;
17986        let modified = metadata
17987            .modified()
17988            .ok()
17989            .map_or_else(|| "-".to_string(), format_system_time);
17990
17991        if metadata.is_dir() {
17992            handle_preview_dir_entry(
17993                root,
17994                &path,
17995                &name,
17996                modified,
17997                depth,
17998                parent_row_id,
17999                row_id,
18000                next_row_id,
18001                budget,
18002                stats,
18003                rows,
18004                languages,
18005                include_patterns,
18006                exclude_patterns,
18007            )?;
18008            continue;
18009        }
18010
18011        if metadata.is_file() {
18012            handle_preview_file_entry(
18013                root,
18014                &path,
18015                &name,
18016                modified,
18017                depth,
18018                parent_row_id,
18019                row_id,
18020                budget,
18021                stats,
18022                rows,
18023                languages,
18024                include_patterns,
18025                exclude_patterns,
18026            );
18027        }
18028    }
18029
18030    Ok(())
18031}
18032
18033fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
18034    if let Some(language) = language {
18035        return format!("{language} source");
18036    }
18037    let lower = name.to_ascii_lowercase();
18038    let ext = Path::new(&lower)
18039        .extension()
18040        .and_then(|e| e.to_str())
18041        .unwrap_or("");
18042    match kind {
18043        PreviewKind::Skipped => {
18044            if lower.ends_with(".min.js") {
18045                "Minified asset".to_string()
18046            } else if [
18047                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
18048            ]
18049            .contains(&ext)
18050            {
18051                "Binary or archive".to_string()
18052            } else {
18053                "Skipped file".to_string()
18054            }
18055        }
18056        PreviewKind::Unsupported => {
18057            if ext.is_empty() {
18058                "Unsupported file".to_string()
18059            } else {
18060                format!("{} file", ext.to_ascii_uppercase())
18061            }
18062        }
18063        PreviewKind::Supported => "Supported source".to_string(),
18064        PreviewKind::Dir => "Directory".to_string(),
18065    }
18066}
18067
18068fn format_system_time(time: SystemTime) -> String {
18069    #[allow(clippy::cast_possible_wrap)]
18070    let secs = match time.duration_since(UNIX_EPOCH) {
18071        Ok(duration) => duration.as_secs() as i64,
18072        Err(_) => return "-".to_string(),
18073    };
18074    let days = secs.div_euclid(86_400);
18075    let secs_of_day = secs.rem_euclid(86_400);
18076    let (year, month, day) = civil_from_days(days);
18077    let hour = secs_of_day / 3_600;
18078    let minute = (secs_of_day % 3_600) / 60;
18079    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
18080}
18081
18082#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
18083fn civil_from_days(days: i64) -> (i32, u32, u32) {
18084    let z = days + 719_468;
18085    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
18086    let doe = z - era * 146_097;
18087    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
18088    let y = yoe + era * 400;
18089    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
18090    let mp = (5 * doy + 2) / 153;
18091    let d = doy - (153 * mp + 2) / 5 + 1;
18092    let m = mp + if mp < 10 { 3 } else { -9 };
18093    let year = y + i64::from(m <= 2);
18094    (year as i32, m as u32, d as u32)
18095}
18096
18097// The input is already lowercased via `to_ascii_lowercase()` before calling
18098// `ends_with`, so the comparisons are inherently case-insensitive.
18099#[allow(clippy::case_sensitive_file_extension_comparisons)]
18100fn detect_language_name(name: &str) -> Option<&'static str> {
18101    let lower = name.to_ascii_lowercase();
18102    if lower.ends_with(".c") || lower.ends_with(".h") {
18103        Some("C")
18104    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
18105        .iter()
18106        .any(|s| lower.ends_with(s))
18107    {
18108        Some("C++")
18109    } else if lower.ends_with(".cs") {
18110        Some("C#")
18111    } else if lower.ends_with(".py") {
18112        Some("Python")
18113    } else if lower.ends_with(".sh") {
18114        Some("Shell")
18115    } else if [".ps1", ".psm1", ".psd1"]
18116        .iter()
18117        .any(|s| lower.ends_with(s))
18118    {
18119        Some("PowerShell")
18120    } else {
18121        None
18122    }
18123}
18124
18125fn language_icon_file(language: &str) -> Option<&'static str> {
18126    match language {
18127        "C" => Some("c.png"),
18128        "C++" => Some("cpp.png"),
18129        "C#" => Some("c-sharp.png"),
18130        "Python" => Some("python.png"),
18131        "Shell" => Some("shell.png"),
18132        "PowerShell" => Some("powershell.png"),
18133        "JavaScript" => Some("java-script.png"),
18134        "HTML" => Some("html-5.png"),
18135        "Java" => Some("java.png"),
18136        "Visual Basic" => Some("visual-basic.png"),
18137        "Assembly" => Some("asm.png"),
18138        "Go" => Some("go.png"),
18139        "R" => Some("r.png"),
18140        "XML" => Some("xml.png"),
18141        "Groovy" => Some("groovy.png"),
18142        "Dockerfile" => Some("docker.png"),
18143        "Makefile" => Some("makefile.svg"),
18144        "Perl" => Some("perl.svg"),
18145        _ => None,
18146    }
18147}
18148
18149// Inline SVG badges for languages that have no PNG icon in images/icons/.
18150// Using inline SVG keeps the web UI fully self-contained — no extra files
18151// needed on disk, no 404s on air-gapped deployments.
18152// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
18153fn language_inline_svg(language: &str) -> Option<&'static str> {
18154    match language {
18155        "Rust" => Some(
18156            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>"##,
18157        ),
18158        "TypeScript" => Some(
18159            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>"##,
18160        ),
18161        _ => None,
18162    }
18163}
18164
18165// The input is already lowercased via `to_ascii_lowercase()` before the
18166// `ends_with` calls, so these comparisons are inherently case-insensitive.
18167#[allow(clippy::case_sensitive_file_extension_comparisons)]
18168fn classify_preview_file(name: &str) -> PreviewKind {
18169    let lower = name.to_ascii_lowercase();
18170
18171    let scannable = [
18172        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
18173        ".psm1", ".psd1",
18174    ]
18175    .iter()
18176    .any(|suffix| lower.ends_with(suffix));
18177
18178    if scannable {
18179        PreviewKind::Supported
18180    } else if lower.ends_with(".min.js")
18181        || lower.ends_with(".lock")
18182        || lower.ends_with(".png")
18183        || lower.ends_with(".jpg")
18184        || lower.ends_with(".jpeg")
18185        || lower.ends_with(".gif")
18186        || lower.ends_with(".zip")
18187        || lower.ends_with(".pdf")
18188        || lower.ends_with(".pyc")
18189        || lower.ends_with(".xz")
18190        || lower.ends_with(".tar")
18191        || lower.ends_with(".gz")
18192    {
18193        PreviewKind::Skipped
18194    } else {
18195        PreviewKind::Unsupported
18196    }
18197}
18198
18199fn preview_relative_path(root: &Path, path: &Path) -> String {
18200    path.strip_prefix(root)
18201        .ok()
18202        .unwrap_or(path)
18203        .to_string_lossy()
18204        .replace('\\', "/")
18205        .trim_matches('/')
18206        .to_string()
18207}
18208
18209fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
18210    if relative.is_empty() {
18211        return false;
18212    }
18213
18214    exclude_patterns.iter().any(|pattern| {
18215        wildcard_match(pattern, relative)
18216            || wildcard_match(pattern, &format!("{relative}/"))
18217            || wildcard_match(pattern, &format!("{relative}/placeholder"))
18218    })
18219}
18220
18221fn should_include_preview_file(
18222    relative: &str,
18223    include_patterns: &[String],
18224    exclude_patterns: &[String],
18225) -> bool {
18226    if relative.is_empty() {
18227        return true;
18228    }
18229
18230    let included = include_patterns.is_empty()
18231        || include_patterns
18232            .iter()
18233            .any(|pattern| wildcard_match(pattern, relative));
18234    let excluded = exclude_patterns
18235        .iter()
18236        .any(|pattern| wildcard_match(pattern, relative));
18237
18238    included && !excluded
18239}
18240
18241fn wildcard_match(pattern: &str, candidate: &str) -> bool {
18242    let pattern = pattern.trim().replace('\\', "/");
18243    let candidate = candidate.trim().replace('\\', "/");
18244    let p = pattern.as_bytes();
18245    let c = candidate.as_bytes();
18246    let mut pi = 0usize;
18247    let mut ci = 0usize;
18248    let mut star: Option<usize> = None;
18249    let mut star_match = 0usize;
18250
18251    while ci < c.len() {
18252        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
18253            pi += 1;
18254            ci += 1;
18255        } else if pi < p.len() && p[pi] == b'*' {
18256            while pi < p.len() && p[pi] == b'*' {
18257                pi += 1;
18258            }
18259            star = Some(pi);
18260            star_match = ci;
18261        } else if let Some(star_pi) = star {
18262            star_match += 1;
18263            ci = star_match;
18264            pi = star_pi;
18265        } else {
18266            return false;
18267        }
18268    }
18269
18270    while pi < p.len() && p[pi] == b'*' {
18271        pi += 1;
18272    }
18273
18274    pi == p.len()
18275}
18276
18277fn escape_html(value: &str) -> String {
18278    value
18279        .replace('&', "&amp;")
18280        .replace('<', "&lt;")
18281        .replace('>', "&gt;")
18282        .replace('"', "&quot;")
18283        .replace('\'', "&#39;")
18284}
18285
18286#[derive(Clone)]
18287struct SubmoduleRow {
18288    name: String,
18289    relative_path: String,
18290    files_analyzed: u64,
18291    code_lines: u64,
18292    comment_lines: u64,
18293    blank_lines: u64,
18294    total_physical_lines: u64,
18295    html_url: Option<String>,
18296}
18297
18298#[derive(Template)]
18299#[template(
18300    source = r##"
18301<!doctype html>
18302<html lang="en">
18303<head>
18304  <meta charset="utf-8">
18305  <title>OxideSLOC | tmp-sloc</title>
18306  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18307  <style nonce="{{ csp_nonce }}">
18308    :root {
18309      --bg: #efe9e2;
18310      --surface: #fcfaf7;
18311      --surface-2: #f7f0e8;
18312      --surface-3: #efe3d5;
18313      --line: #dfcfbf;
18314      --line-strong: #cfb29c;
18315      --text: #2f241c;
18316      --muted: #6f6257;
18317      --muted-2: #917f71;
18318      --nav: #b85d33;
18319      --nav-2: #7a371b;
18320      --accent: #2563eb;
18321      --accent-2: #1d4ed8;
18322      --oxide: #b85d33;
18323      --oxide-2: #8f4220;
18324      --success-bg: #eaf9ee;
18325      --success-text: #1c8746;
18326      --warn-bg: #fff2d8;
18327      --warn-text: #926000;
18328      --danger-bg: #fdeaea;
18329      --danger-text: #b33b3b;
18330      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18331      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18332      --radius: 14px;
18333    }
18334
18335    body.dark-theme {
18336      --bg: #1b1511;
18337      --surface: #261c17;
18338      --surface-2: #2d221d;
18339      --surface-3: #372922;
18340      --line: #524238;
18341      --line-strong: #6c5649;
18342      --text: #f5ece6;
18343      --muted: #c7b7aa;
18344      --muted-2: #aa9485;
18345      --nav: #b85d33;
18346      --nav-2: #7a371b;
18347      --accent: #6f9bff;
18348      --accent-2: #4a78ee;
18349      --oxide: #d37a4c;
18350      --oxide-2: #b35428;
18351      --success-bg: #163927;
18352      --success-text: #8fe2a8;
18353      --warn-bg: #3c2d11;
18354      --warn-text: #f3cb75;
18355      --danger-bg: #3d1f1f;
18356      --danger-text: #ff9f9f;
18357      --shadow: 0 14px 28px rgba(0,0,0,0.28);
18358      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18359    }
18360
18361    * { box-sizing: border-box; }
18362    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); }
18363    html { overflow-y: scroll; }
18364    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18365    .top-nav, .page, .loading { position: relative; z-index: 2; }
18366    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18367    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18368    .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); }
18369    .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; }
18370    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18371    .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)); }
18372    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18373    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18374    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18375    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18376    .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; }
18377    .nav-project-pill.visible { display:inline-flex; }
18378    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18379    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18380    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18381    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18382    @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; } }
18383    .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; }
18384    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18385    .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; }
18386    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18387    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18388    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18389    .theme-toggle .icon-sun { display:none; }
18390    body.dark-theme .theme-toggle .icon-sun { display:block; }
18391    body.dark-theme .theme-toggle .icon-moon { display:none; }
18392    .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;}
18393    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18394    .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);}
18395    .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;}
18396    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18397    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18398    .settings-modal-body{padding:14px 16px 16px;}
18399    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18400    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18401    .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;}
18402    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18403    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18404    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18405    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18406    .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;}
18407    .tz-select:focus{border-color:var(--oxide);}
18408    .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; }
18409    .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;}
18410    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18411    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18412    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18413    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18414    .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; }
18415    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18416    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18417    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18418    .wb-stats-header { padding: 10px 24px 0; }
18419    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18420    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18421    .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; }
18422    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18423    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18424    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18425    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18426    .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; }
18427    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18428    .ws-stat-analyzers { position: relative; }
18429    .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; }
18430    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18431    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18432    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18433    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18434    .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; }
18435    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18436    .ws-divider { display: none; }
18437    .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%; }
18438    .ws-path-link:hover { color:var(--oxide); }
18439    body.dark-theme .ws-path-link { color:var(--oxide); }
18440    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18441    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18442    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18443    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18444    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18445    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18446    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18447    .ws-mini-box-lg { flex:2 1 0; }
18448    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18449    .ws-mini-box-br { flex:1.5 1 0; }
18450    .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; }
18451    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18452    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18453    #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; }
18454    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18455    .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; }
18456    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18457    .git-source-banner strong { font-weight:800; color:var(--text); }
18458    .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; }
18459    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18460    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18461    .git-source-banner a:hover { text-decoration:underline; }
18462    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18463    .path-scope-sep { background:var(--line); margin:4px 14px; }
18464    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18465    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18466    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18467    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18468    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18469    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18470    .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; }
18471    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18472    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18473    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18474    .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; }
18475    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18476    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18477    [data-wb-tip] { cursor:help; }
18478    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18479    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18480    .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; }
18481    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18482    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18483    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18484    .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; }
18485    .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); }
18486    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18487    .side-info-card { padding: 18px; }
18488    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18489    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18490    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18491    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18492    .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); }
18493    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18494    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18495    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18496    .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; }
18497    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18498    .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; }
18499    .side-stack::-webkit-scrollbar { display: none; }
18500    .step-nav { padding: 20px 16px; }
18501    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18502    .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; }
18503    .step-button:hover { background: var(--surface-2); }
18504    .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); }
18505    .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; }
18506    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18507    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18508    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18509    .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); }
18510    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18511    .step-nav-sum-row:last-child { border-bottom:none; }
18512    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18513    .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; }
18514    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18515    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18516    .quick-scan-section { padding: 10px 4px 14px; }
18517    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18518    .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; }
18519    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18520    .quick-scan-btn:active { transform:translateY(0); }
18521    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18522    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18523    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18524    @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);} }
18525    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18526    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18527    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18528    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18529    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18530    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18531    .step-button.done .step-check { opacity:1; }
18532    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18533    .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; }
18534    .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; }
18535    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18536    .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; }
18537    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18538    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18539    .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; }
18540    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18541    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18542    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18543    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18544    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18545    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18546    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18547    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18548    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18549    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18550    .card-body { padding: 22px; }
18551    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18552    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18553    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18554    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18555    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18556    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18557    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18558    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18559    .field { min-width:0; }
18560    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18561    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; }
18562    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); }
18563    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18564    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); }
18565    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18566    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18567    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18568    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18569    .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; }
18570    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18571    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18572    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18573    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18574    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18575    .input-group.compact { grid-template-columns: 1fr auto auto; }
18576    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18577    .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)); }
18578    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18579    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18580    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18581    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18582    .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; }
18583    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18584    .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; }
18585    .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); }
18586    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18587    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18588    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18589    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18590    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18591    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18592    @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; } }
18593    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18594    button.secondary { background: var(--surface); }
18595    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18596    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18597    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18598    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18599    .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); }
18600    .section + .wizard-actions { border-top: none; padding-top: 0; }
18601    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18602    .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; }
18603    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18604    .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; }
18605    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18606    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18607    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18608    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18609    .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); }
18610    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18611    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18612    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18613    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18614    .field-help-grid.coupled-help { margin-top: 12px; }
18615    .field-help-grid.preset-grid { align-items: start; }
18616    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18617    .preset-inline-row .field { margin: 0; }
18618    .preset-inline-row .explainer-card { margin: 0; }
18619    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18620    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18621    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18622    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18623    .preset-kv-row > :last-child { flex:1; min-width:0; }
18624    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18625    .output-field-row .field { margin: 0; }
18626    .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; }
18627    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18628    .step3-subtitle { margin-bottom: 10px; max-width: none; }
18629    .counting-intro { margin-bottom: 8px; max-width: none; }
18630    .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; }
18631    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18632    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18633    .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; }
18634    .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; }
18635    .section-spacer-top { margin-top: 28px; }
18636    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18637    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18638    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18639    .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); }
18640    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18641    .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; }
18642    .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; }
18643    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18644    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18645    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18646    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18647    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18648    .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; }
18649    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18650    .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); }
18651    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; }
18652    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; }
18653    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18654    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18655    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18656    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18657    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18658    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18659    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18660    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18661    .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); }
18662    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18663    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18664    .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; }
18665    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18666    .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; }
18667    .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; }
18668    .always-tracked-tip-body { flex:1; min-width:0; }
18669    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18670    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18671    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18672    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18673    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18674    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18675    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18676    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18677    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18678    .advanced-rule-description strong { color: var(--text); }
18679    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18680    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18681    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18682    .review-link:hover { text-decoration: underline; }
18683    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18684    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18685    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18686    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18687    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18688    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18689    .review-card ul { padding-left: 18px; margin: 0; }
18690    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18691    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18692    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18693    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18694    .review-card { min-height: 0; }
18695    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18696    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18697    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18698    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18699    .lang-overflow-chip { position:relative; cursor:default; }
18700    .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; }
18701    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18702    .git-inline-row { align-items:start; }
18703    .mixed-line-card { display:flex; flex-direction:column; }
18704    .preset-inline-row .toggle-card { justify-content: center; }
18705        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18706    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18707    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18708    .explorer-title { font-size: 18px; font-weight: 850; }
18709    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18710    .explorer-subtitle.wide { max-width: none; }
18711    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18712    .better-spacing { align-items:flex-start; justify-content:flex-end; }
18713    .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; }
18714    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18715    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18716    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18717    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18718    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18719    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18720    .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; }
18721    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18722    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18723    .scope-stat-button.supported { background: var(--success-bg); }
18724    .scope-stat-button.skipped { background: var(--warn-bg); }
18725    .scope-stat-button.unsupported { background: var(--danger-bg); }
18726    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18727    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18728    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18729    [data-tooltip] { position: relative; }
18730    [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); }
18731    [data-tooltip]:hover::after { display: block; }
18732    .scope-stat-button[data-tooltip] { cursor: pointer; }
18733    .badge[data-tooltip] { cursor: help; }
18734    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18735    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18736    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18737    .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; }
18738    .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; }
18739    code { display:inline-block; margin-top:0; padding:2px 7px; }
18740    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18741    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18742    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18743    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18744    .language-pill.muted-pill { color: var(--muted); }
18745    button.language-pill { appearance:none; cursor:pointer; }
18746    .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); }
18747    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18748    .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; }
18749    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18750    .file-explorer-search-row { margin-left: auto; }
18751    .explorer-filter-select { min-width: 170px; width: 170px; }
18752    .explorer-search { min-width: 300px; width: 300px; }
18753    .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); }
18754    .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; }
18755    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18756    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18757    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18758    .file-explorer-tree { max-height: 640px; overflow:auto; }
18759    .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); }
18760    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18761    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18762    .tree-row.hidden-by-filter { display:none !important; }
18763    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18764    .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; }
18765    .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; }
18766    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18767    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18768    .tree-node { display:inline-flex; align-items:center; min-width:0; }
18769    .tree-node-dir { color: var(--text); font-weight: 800; }
18770    .tree-node-supported { color: var(--success-text); }
18771    .tree-node-skipped { color: var(--warn-text); }
18772    .tree-node-unsupported { color: var(--danger-text); }
18773    .tree-node-more { color: var(--muted-2); font-style: italic; }
18774    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18775    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18776    .tree-status-cell { display:flex; justify-content:flex-start; }
18777    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18778    .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; }
18779    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18780    .preview-warning p { margin: 0 0 10px; }
18781    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18782    .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; }
18783    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18784    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18785    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18786    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18787    .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; }
18788    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18789    .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; }
18790    @keyframes prevSpin { to { transform:rotate(360deg); } }
18791    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18792    .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; }
18793    .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; }
18794    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18795    .preview-gate-info svg { width:16px; height:16px; }
18796    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18797    @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); } }
18798    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18799    .preview-loading-text { flex:1; min-width:0; }
18800    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18801    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18802    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18803    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18804    .cov-scan-idle { display:none; }
18805    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18806    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18807    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18808    .cov-scan-title { font-weight:600; font-size:12.5px; }
18809    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18810    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18811    .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; }
18812    .cov-scan-use:hover { opacity:.75; }
18813    .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; }
18814    .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; }
18815    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18816    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18817    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18818    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18819    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18820    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18821    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18822    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18823    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18824    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18825    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18826    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18827    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18828    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18829    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18830    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18831    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18832    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18833    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18834    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18835    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18836    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18837    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18838    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18839    .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); }
18840    .loading.active { display:flex; }
18841    /* Lock page scroll while the analysis modal is open so the removed scrollbar
18842       gutter doesn't pull the centered card slightly left of true center. */
18843    body.modal-open { overflow: hidden; }
18844    .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; }
18845    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18846    .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; }
18847    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18848    .loading-card > * { position:relative; z-index:1; }
18849    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18850    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%); }
18851    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18852    .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; }
18853    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18854    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18855    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18856    .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; }
18857    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18858    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18859    .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; }
18860    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18861    .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; }
18862    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18863    .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; }
18864    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18865    .lc-step.done { color:var(--muted);opacity:0.55; }
18866    .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; }
18867    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18868    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18869    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18870    .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; }
18871    .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; }
18872    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18873    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18874    .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; }
18875    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18876    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18877    .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; }
18878    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18879    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18880    .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; }
18881    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18882    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18883    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18884    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18885    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18886    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18887    .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; }
18888    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18889    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18890    .hidden { display:none !important; }
18891    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18892    .site-footer a{color:var(--muted);}
18893    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18894    @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; } }
18895    .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;}
18896    @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));}}
18897    .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;}
18898    .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; }
18899    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18900    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18901    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18902    .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; }
18903    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18904    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18905    .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; }
18906    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18907    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18908    .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; }
18909    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18910    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18911    .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; }
18912    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18913    .info-icon-btn:hover { color:var(--text); }
18914    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); }
18915    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18916    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18917    .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;}
18918    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18919    .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;}
18920    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18921    #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);}
18922    #offline-file-banner.show{display:flex;}
18923    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18924    #offline-file-banner .ofb-text{flex:1;}
18925    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18926    #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;}
18927    #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;}
18928    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18929    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18930    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18931    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18932    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18933    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18934    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18935  </style>
18936</head>
18937<body id="page-top">
18938  <div id="offline-file-banner" role="alert">
18939    <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>
18940    <span class="ofb-text">
18941      Charts, images, and navigation require the oxide-sloc server.
18942      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18943      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18944      The metric tables below are fully readable without the server.
18945    </span>
18946    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18947  </div>
18948  <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>
18949  <div class="background-watermarks" aria-hidden="true">
18950    <img src="/images/logo/logo-text.png" alt="" />
18951    <img src="/images/logo/logo-text.png" alt="" />
18952    <img src="/images/logo/logo-text.png" alt="" />
18953    <img src="/images/logo/logo-text.png" alt="" />
18954    <img src="/images/logo/logo-text.png" alt="" />
18955    <img src="/images/logo/logo-text.png" alt="" />
18956    <img src="/images/logo/logo-text.png" alt="" />
18957    <img src="/images/logo/logo-text.png" alt="" />
18958    <img src="/images/logo/logo-text.png" alt="" />
18959    <img src="/images/logo/logo-text.png" alt="" />
18960    <img src="/images/logo/logo-text.png" alt="" />
18961    <img src="/images/logo/logo-text.png" alt="" />
18962    <img src="/images/logo/logo-text.png" alt="" />
18963    <img src="/images/logo/logo-text.png" alt="" />
18964  </div>
18965  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18966  <div class="top-nav">
18967    <div class="top-nav-inner">
18968      <a class="brand" href="/">
18969        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18970        <div class="brand-copy">
18971          <div class="brand-title">OxideSLOC</div>
18972          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18973        </div>
18974      </a>
18975      <div class="nav-project-slot">
18976        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18977          <span class="nav-project-label">Project</span>
18978          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18979        </div>
18980      </div>
18981      <div class="nav-status">
18982        <a class="nav-pill" href="/">Home</a>
18983        <div class="nav-dropdown">
18984          <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>
18985          <div class="nav-dropdown-menu">
18986            <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>
18987          </div>
18988        </div>
18989        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18990        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18991        <div class="nav-dropdown">
18992          <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>
18993          <div class="nav-dropdown-menu">
18994            <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>
18995          </div>
18996        </div>
18997        <div class="server-status-wrap" id="server-status-wrap">
18998          <div class="nav-pill server-online-pill" id="server-status-pill">
18999            <span class="status-dot" id="status-dot"></span>
19000            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
19001            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
19002          </div>
19003          <div class="server-status-tip">
19004            {% if server_mode %}
19005            OxideSLOC is running in server mode — accessible on your LAN.
19006            {% else %}
19007            OxideSLOC is running locally — only accessible from this machine.
19008            {% endif %}
19009            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
19010          </div>
19011        </div>
19012        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
19013          <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>
19014        </button>
19015        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
19016          <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>
19017          <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>
19018        </button>
19019      </div>
19020    </div>
19021  </div>
19022
19023  <div class="loading" id="loading">
19024    <div class="loading-card" id="loading-card">
19025      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
19026      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
19027      <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>
19028      <div class="lc-steps" id="lc-steps">
19029        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
19030        <div class="lc-step-arrow">›</div>
19031        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
19032        <div class="lc-step-arrow">›</div>
19033        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
19034        <div class="lc-step-arrow">›</div>
19035        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
19036      </div>
19037      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
19038      <div class="lc-metrics" id="lc-metrics">
19039        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
19040        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
19041        <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>
19042        <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>
19043      </div>
19044      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
19045      <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>
19046      <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>
19047      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
19048      <div class="lc-actions hidden" id="lc-actions">
19049        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
19050        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
19051      </div>
19052      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
19053        <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>
19054        Cancel scan
19055      </button>
19056    </div>
19057  </div>
19058
19059  <div class="page">
19060    <div class="workbench-strip">
19061      <div class="workbench-box wb-stats">
19062        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
19063          <span class="wb-stats-title">Analysis session</span>
19064        </div>
19065        <div class="ws-left">
19066          <div class="ws-stat ws-stat-analyzers">
19067            <span class="ws-label">Analyzers</span>
19068            <span class="ws-value">
19069              <span class="ws-badge">60 languages</span>
19070            </span>
19071            <div class="ws-lang-tooltip">
19072              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
19073              <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>
19074              <div class="ws-lang-grid">
19075                <span class="ws-lang-item">Assembly</span>
19076                <span class="ws-lang-item">C</span>
19077                <span class="ws-lang-item">C++</span>
19078                <span class="ws-lang-item">C#</span>
19079                <span class="ws-lang-item">Clojure</span>
19080                <span class="ws-lang-item">CSS</span>
19081                <span class="ws-lang-item">Dart</span>
19082                <span class="ws-lang-item">Dockerfile</span>
19083                <span class="ws-lang-item">Elixir</span>
19084                <span class="ws-lang-item">Erlang</span>
19085                <span class="ws-lang-item">F#</span>
19086                <span class="ws-lang-item">Go</span>
19087                <span class="ws-lang-item">Groovy</span>
19088                <span class="ws-lang-item">Haskell</span>
19089                <span class="ws-lang-item">HTML</span>
19090                <span class="ws-lang-item">Java</span>
19091                <span class="ws-lang-item">JavaScript</span>
19092                <span class="ws-lang-item">Julia</span>
19093                <span class="ws-lang-item">Kotlin</span>
19094                <span class="ws-lang-item">Lua</span>
19095                <span class="ws-lang-item">Makefile</span>
19096                <span class="ws-lang-item">Nim</span>
19097                <span class="ws-lang-item">Obj-C</span>
19098                <span class="ws-lang-item">OCaml</span>
19099                <span class="ws-lang-item">Perl</span>
19100                <span class="ws-lang-item">PHP</span>
19101                <span class="ws-lang-item">PowerShell</span>
19102                <span class="ws-lang-item">Python</span>
19103                <span class="ws-lang-item">R</span>
19104                <span class="ws-lang-item">Ruby</span>
19105                <span class="ws-lang-item">Rust</span>
19106                <span class="ws-lang-item">Scala</span>
19107                <span class="ws-lang-item">SCSS</span>
19108                <span class="ws-lang-item">Shell</span>
19109                <span class="ws-lang-item">SQL</span>
19110                <span class="ws-lang-item">Svelte</span>
19111                <span class="ws-lang-item">Swift</span>
19112                <span class="ws-lang-item">TypeScript</span>
19113                <span class="ws-lang-item">Vue</span>
19114                <span class="ws-lang-item">XML</span>
19115                <span class="ws-lang-item">Zig</span>
19116                <span class="ws-lang-item">Solidity</span>
19117                <span class="ws-lang-item">Protobuf</span>
19118                <span class="ws-lang-item">HCL</span>
19119                <span class="ws-lang-item">GraphQL</span>
19120                <span class="ws-lang-item">Ada</span>
19121                <span class="ws-lang-item">VHDL</span>
19122                <span class="ws-lang-item">Verilog</span>
19123                <span class="ws-lang-item">Tcl</span>
19124                <span class="ws-lang-item">Pascal</span>
19125                <span class="ws-lang-item">Visual Basic</span>
19126                <span class="ws-lang-item">Lisp</span>
19127                <span class="ws-lang-item">Fortran</span>
19128                <span class="ws-lang-item">Nix</span>
19129                <span class="ws-lang-item">Crystal</span>
19130                <span class="ws-lang-item">D</span>
19131                <span class="ws-lang-item">GLSL</span>
19132                <span class="ws-lang-item">CMake</span>
19133                <span class="ws-lang-item">Elm</span>
19134                <span class="ws-lang-item">Awk</span>
19135              </div>
19136            </div>
19137          </div>
19138          <div class="ws-divider"></div>
19139          <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>
19140          <div class="ws-divider"></div>
19141          <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.">
19142            <span class="ws-label">Output</span>
19143            <span class="ws-value">
19144              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
19145                <span id="ws-output-root">project/sloc</span>
19146              </button>
19147            </span>
19148          </div>
19149        </div>
19150      </div>
19151      <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.">
19152        <div class="ws-history-label">Scan history</div>
19153        <div class="ws-history-inner">
19154          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
19155            <div class="ws-mini-label">Scans</div>
19156            <div class="ws-mini-value" id="ws-scan-count">—</div>
19157          </div>
19158          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
19159            <div class="ws-mini-label">Last Scan</div>
19160            <div class="ws-mini-value" id="ws-last-scan">—</div>
19161          </div>
19162          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
19163            <div class="ws-mini-label">Branch</div>
19164            <div class="ws-mini-value" id="ws-branch">—</div>
19165          </div>
19166        </div>
19167      </div>
19168    </div>
19169
19170    <div class="layout">
19171      <aside class="side-stack">
19172        <section class="step-nav">
19173        <h3>Guided scan setup</h3>
19174        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
19175          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
19176          Top of page
19177        </a>
19178        <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>
19179        <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>
19180        <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>
19181        <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>
19182
19183        <div class="step-steps-divider"></div>
19184
19185        <div class="step-nav-info" id="step-nav-info">
19186          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
19187          <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>
19188        </div>
19189
19190        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
19191          <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>
19192          <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>
19193          <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>
19194        </div>
19195
19196        <div class="quick-scan-divider"></div>
19197        <div class="quick-scan-section">
19198          <div class="quick-scan-label">No customization needed?</div>
19199          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
19200            <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>
19201            Quick Scan
19202          </button>
19203          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
19204        </div>
19205
19206        <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>
19207        <div class="sidebar-scroll-divider"></div>
19208        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
19209          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
19210          Skip to bottom
19211        </a>
19212        </section>
19213
19214      </aside>
19215
19216      <section class="card">
19217        <div class="card-header">
19218          <div class="card-title-row">
19219            <div>
19220              <h1 class="card-title">Guided scan configuration</h1>
19221              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
19222            </div>
19223            <div class="wizard-progress" aria-label="Scan setup progress">
19224              <div class="wizard-progress-top">
19225                <span class="wizard-progress-label">Setup progress</span>
19226                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
19227              </div>
19228              <div class="wizard-progress-track">
19229                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
19230              </div>
19231            </div>
19232          </div>
19233        </div>
19234        <div class="card-body">
19235          <form method="post" action="/analyze" id="analyze-form">
19236            <div class="wizard-step active" data-step="1">
19237              <div class="section">
19238                <div class="section-kicker">Step 1</div>
19239                <h2>Select project and preview scope</h2>
19240                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
19241                <div class="field">
19242                  <label for="path">Project path</label>
19243                  {% if !git_repo.is_empty() %}
19244                  <div class="git-source-banner">
19245                    <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>
19246                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
19247                    <a href="/git-browser">← Back to Git Browser</a>
19248                  </div>
19249                  {% endif %}
19250                  <div class="path-scope-grid">
19251                      {% if !git_repo.is_empty() %}
19252                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
19253                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
19254                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
19255                      {% else %}
19256                      <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
19257                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
19258                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
19259                      {% endif %}
19260                    <div class="path-scope-sep"></div>
19261                    <div class="scope-legend-row">
19262                      <span class="scope-legend-label">Scope legend:</span>
19263                      <span class="scope-legend-badges">
19264                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
19265                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
19266                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
19267                      </span>
19268                    </div>
19269                  </div>
19270                  {% if git_repo.is_empty() %}
19271                  {% if server_mode %}
19272                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
19273                    ℹ️ Files are compressed and streamed — no fixed size limit.
19274                  </div>
19275                  {% endif %}
19276                  <div class="path-info-row">
19277                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
19278                      <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>
19279                      <span id="project-size-text">Project size: —</span>
19280                    </button>
19281                  </div>
19282                  {% else %}
19283                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
19284                  {% endif %}
19285                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
19286                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
19287                </div>
19288
19289                <div class="scope-preview-divider" aria-hidden="true"></div>
19290
19291                <div id="preview-panel">
19292                  <div class="preview-error">Loading preview...</div>
19293                </div>
19294              </div>
19295
19296              <div class="section" style="margin-top:14px;">
19297                <div class="preset-inline-row git-inline-row">
19298                  <div class="toggle-card" style="margin:0;">
19299                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
19300                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
19301                    <label class="checkbox">
19302                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
19303                      <div>
19304                        <span>Detect and separate git submodules</span>
19305                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
19306                      </div>
19307                    </label>
19308                  </div>
19309                  <div class="explainer-card prominent" style="margin:0;">
19310                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19311                    <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>
19312                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
19313    path = libs/core
19314    url  = https://github.com/org/core.git
19315
19316[submodule "libs/ui"]
19317    path = libs/ui
19318    url  = https://github.com/org/ui.git</div>
19319                  </div>
19320                </div>
19321              </div>
19322
19323              <div class="section">
19324                <div class="field-grid">
19325                  <div class="field">
19326                    <div class="glob-label-row">
19327                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19328                      <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>
19329                    </div>
19330                    <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>
19331                    <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>
19332                  </div>
19333                  <div class="field">
19334                    <div class="glob-label-row">
19335                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19336                    </div>
19337                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
19338                    <div id="quick-exclude-chips" class="quick-excl-row">
19339                      <span class="quick-excl-label">Quick add:</span>
19340                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19341                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19342                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19343                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19344                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19345                      <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>
19346                    </div>
19347                    <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>
19348                  </div>
19349                </div>
19350                <div class="glob-guidance-grid">
19351                  <div class="glob-guidance-card">
19352                    <strong>How to read them</strong>
19353                    <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>
19354                  </div>
19355                  <div class="glob-guidance-card">
19356                    <strong>Common include examples</strong>
19357                    <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>
19358                  </div>
19359                  <div class="glob-guidance-card">
19360                    <strong>Common exclude examples</strong>
19361                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19362                  </div>
19363                </div>
19364              </div>
19365
19366              <div class="section" style="margin-top:14px;">
19367                <div class="preset-inline-row git-inline-row">
19368                  <div class="toggle-card" style="margin:0;">
19369                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19370                    <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>
19371                    <div class="field" style="margin:0;">
19372                      <div class="input-group compact">
19373                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19374                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19375                      </div>
19376                      <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>
19377                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19378                    </div>
19379                  </div>
19380                  <div class="explainer-card prominent" style="margin:0;">
19381                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19382                    <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>
19383                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19384lcov --capture --directory . --output-file coverage/lcov.info
19385
19386# C / C++ — llvm-cov (LCOV)
19387llvm-profdata merge -sparse default.profraw -o default.profdata
19388llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19389
19390# C# — coverlet (Cobertura XML)
19391dotnet test --collect:"XPlat Code Coverage"
19392
19393# Python — pytest-cov (Cobertura XML)
19394pytest --cov --cov-report=xml
19395
19396# Python — coverage.py native JSON
19397coverage run -m pytest && coverage json   # writes coverage.json
19398
19399# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19400./gradlew jacocoTestReport</div>
19401                  </div>
19402                </div>
19403              </div>
19404
19405              <div class="wizard-actions">
19406                <div class="left"></div>
19407                <div class="right">
19408                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19409                    <span class="preview-gate-spinner" aria-hidden="true"></span>
19410                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
19411                    <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">
19412                      <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>
19413                    </button>
19414                  </div>
19415                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19416                </div>
19417              </div>
19418            </div>
19419
19420            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19421              <div class="default-path-modal">
19422                <h3 id="default-path-title">
19423                  <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>
19424                  Proceed with the default sample test?
19425                </h3>
19426                <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19427                <p>You haven&#39;t selected your own project yet.</p>
19428                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19429                <div class="default-path-actions">
19430                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19431                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19432                </div>
19433              </div>
19434            </div>
19435
19436            <div class="wizard-step" data-step="2">
19437              <div class="section">
19438                <div class="section-kicker">Step 2</div>
19439                <h2>Choose counting behavior</h2>
19440                <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>
19441<div class="subsection-bar">Primary line classification</div>
19442                <div class="preset-kv-row">
19443                  <div class="toggle-card mixed-line-card" style="margin:0;">
19444                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19445                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19446                    <select id="mixed_line_policy" name="mixed_line_policy">
19447                      <option value="code_only">Code only</option>
19448                      <option value="code_and_comment">Code and comment</option>
19449                      <option value="comment_only">Comment only</option>
19450                      <option value="separate_mixed_category">Separate mixed category</option>
19451                    </select>
19452                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19453                  </div>
19454                  <div class="explainer-card prominent" style="margin:0;">
19455                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19456                    <div class="explainer-body" id="mixed-policy-description"></div>
19457                    <div class="code-sample" id="mixed-policy-example"></div>
19458                  </div>
19459                </div>
19460              </div>
19461
19462              <div class="subsection-bar">Additional scan rules</div>
19463              <div class="scan-rules-grid">
19464                <div class="preset-inline-row">
19465                  <div class="toggle-card" style="margin:0;">
19466                    <div class="field-help-title">Generated files</div>
19467                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19468                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19469                  </div>
19470                  <div class="explainer-card prominent" style="margin:0;">
19471                    <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>
19472                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19473# Files matching codegen patterns are excluded:
19474#   *.generated.cs  *.pb.go  *.g.dart</div>
19475                  </div>
19476                </div>
19477                <div class="preset-inline-row">
19478                  <div class="toggle-card" style="margin:0;">
19479                    <div class="field-help-title">Minified files</div>
19480                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19481                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19482                  </div>
19483                  <div class="explainer-card prominent" style="margin:0;">
19484                    <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>
19485                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19486# Heuristic: very long lines + low whitespace ratio
19487#   jquery.min.js  bundle.min.css  → skipped</div>
19488                  </div>
19489                </div>
19490                <div class="preset-inline-row">
19491                  <div class="toggle-card" style="margin:0;">
19492                    <div class="field-help-title">Vendor directories</div>
19493                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19494                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19495                  </div>
19496                  <div class="explainer-card prominent" style="margin:0;">
19497                    <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>
19498                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19499# Directories named vendor/ node_modules/ third_party/
19500#   → entire subtree is excluded from totals</div>
19501                  </div>
19502                </div>
19503                <div class="preset-inline-row">
19504                  <div class="toggle-card" style="margin:0;">
19505                    <div class="field-help-title">Lockfiles and manifests</div>
19506                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19507                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19508                  </div>
19509                  <div class="explainer-card prominent" style="margin:0;">
19510                    <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>
19511                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
19512# Files like package-lock.json  Cargo.lock  yarn.lock
19513#   → skipped unless this is enabled</div>
19514                  </div>
19515                </div>
19516                <div class="preset-inline-row">
19517                  <div class="toggle-card" style="margin:0;">
19518                    <div class="field-help-title">Binary handling</div>
19519                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19520                    <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>
19521                  </div>
19522                  <div class="explainer-card prominent" style="margin:0;">
19523                    <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>
19524                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
19525# Detected via long lines + low whitespace heuristic
19526#   .png  .exe  .so  → skipped silently</div>
19527                  </div>
19528                </div>
19529                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19530                  <div class="toggle-card" style="margin:0;">
19531                    <div class="field-help-title">Python docstrings</div>
19532                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19533                    <label class="checkbox">
19534                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19535                      <span>Count as comment-style lines</span>
19536                    </label>
19537                  </div>
19538                  <div class="explainer-card prominent" style="margin:0;">
19539                    <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>
19540                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19541                  </div>
19542                </div>
19543              </div>
19544              <div class="subsection-bar">IEEE 1045-1992 counting</div>
19545              <div class="scan-rules-grid">
19546                <div class="preset-inline-row">
19547                  <div class="toggle-card" style="margin:0;">
19548                    <div class="field-help-title">Continuation lines</div>
19549                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19550                    <select name="continuation_line_policy" id="continuation_line_policy">
19551                      <option value="each_physical_line" selected>Each physical line (default)</option>
19552                      <option value="collapse_to_logical">Collapse to logical line</option>
19553                    </select>
19554                  </div>
19555                  <div class="explainer-card prominent" style="margin:0;">
19556                    <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>
19557                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19558    ((a) &gt; (b) ? (a) : (b))
19559# each_physical_line → 2 SLOC
19560# collapse_to_logical → 1 SLOC</div>
19561                  </div>
19562                </div>
19563                <div class="preset-inline-row">
19564                  <div class="toggle-card" style="margin:0;">
19565                    <div class="field-help-title">Block-comment blanks</div>
19566                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19567                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19568                      <option value="count_as_comment" selected>Count as comment (default)</option>
19569                      <option value="count_as_blank">Count as blank</option>
19570                    </select>
19571                  </div>
19572                  <div class="explainer-card prominent" style="margin:0;">
19573                    <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>
19574                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19575 * Summary line
19576 *              ← blank inside block comment
19577 * Detail line
19578 */
19579# count_as_comment → blank counts toward comments
19580# count_as_blank   → blank counts toward blanks</div>
19581                  </div>
19582                </div>
19583                <div class="preset-inline-row">
19584                  <div class="toggle-card" style="margin:0;">
19585                    <div class="field-help-title">Compiler directives</div>
19586                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19587                    <select name="count_compiler_directives" id="count_compiler_directives">
19588                      <option value="enabled" selected>Include in code SLOC (default)</option>
19589                      <option value="disabled">Exclude from code SLOC</option>
19590                    </select>
19591                  </div>
19592                  <div class="explainer-card prominent" style="margin:0;">
19593                    <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>
19594                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
19595#define BUF 256     ← compiler directive
19596int main() { … }   ← code
19597# enabled  → 3 code SLOC
19598# disabled → 1 code SLOC + 2 directive lines</div>
19599                  </div>
19600                </div>
19601              </div>
19602
19603              <div class="subsection-bar">Code Style Analysis</div>
19604              <div class="scan-rules-grid">
19605                <div class="preset-inline-row">
19606                  <div class="toggle-card" style="margin:0;">
19607                    <div class="field-help-title">Style analysis</div>
19608                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19609                    <select name="style_analysis_enabled" id="style_analysis_enabled">
19610                      <option value="enabled" selected>Enabled (default)</option>
19611                      <option value="disabled">Disabled — skip style scoring</option>
19612                    </select>
19613                  </div>
19614                  <div class="explainer-card prominent" style="margin:0;">
19615                    <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>
19616                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
19617# style_analysis_enabled = false  (skip, faster scan)
19618# Disabling removes the Code Style section from the report.</div>
19619                  </div>
19620                </div>
19621                <div class="preset-inline-row">
19622                  <div class="toggle-card" style="margin:0;">
19623                    <div class="field-help-title">Column-width threshold</div>
19624                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19625                    <select name="style_col_threshold" id="style_col_threshold">
19626                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19627                      <option value="100">100 columns (Uber Go, Google Java)</option>
19628                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
19629                    </select>
19630                  </div>
19631                  <div class="explainer-card prominent" style="margin:0;">
19632                    <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>
19633                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
19634# style_col_threshold = 100 (Uber Go, Google Java)
19635# style_col_threshold = 120 (Uber Go max, Kotlin)
19636# Files where &lt;= 5% of lines exceed the limit
19637# are counted as "N-col compliant" in the report.</div>
19638                  </div>
19639                </div>
19640                <div class="preset-inline-row">
19641                  <div class="toggle-card" style="margin:0;">
19642                    <div class="field-help-title">Score alert threshold</div>
19643                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19644                    <select name="style_score_threshold" id="style_score_threshold">
19645                      <option value="0" selected>Off — no threshold (default)</option>
19646                      <option value="40">40% — flag poorly styled files</option>
19647                      <option value="50">50% — flag below-average files</option>
19648                      <option value="60">60% — flag below-good files</option>
19649                      <option value="70">70% — flag below-strong files</option>
19650                    </select>
19651                  </div>
19652                  <div class="explainer-card prominent" style="margin:0;">
19653                    <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>
19654                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
19655# style_score_threshold = 50  (flag files &lt; 50%)
19656# Low-scoring files get a red left-border in the
19657# per-file style breakdown table.</div>
19658                  </div>
19659                </div>
19660              </div>
19661
19662              <div class="always-tracked-tip">
19663                <div class="always-tracked-tip-icon">ℹ</div>
19664                <div class="always-tracked-tip-body">
19665                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
19666                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
19667                  <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>
19668                </div>
19669              </div>
19670
19671              <div class="subsection-bar">Advanced Metrics</div>
19672              <div class="scan-rules-grid">
19673                <div class="preset-inline-row">
19674                  <div class="toggle-card" style="margin:0;">
19675                    <div class="field-help-title">COCOMO mode</div>
19676                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19677                    <select name="cocomo_mode" id="cocomo_mode">
19678                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19679                      <option value="semi_detached">Semi-detached — mixed constraints</option>
19680                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
19681                    </select>
19682                  </div>
19683                  <div class="explainer-card prominent" style="margin:0;">
19684                    <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>
19685                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
19686# Semi-detached: Effort = 3.0 × KSLOC^1.12
19687# Embedded:     Effort = 3.6 × KSLOC^1.20
19688# All modes: Schedule = 2.5 × Effort^d</div>
19689                  </div>
19690                </div>
19691                <div class="preset-inline-row">
19692                  <div class="toggle-card" style="margin:0;">
19693                    <div class="field-help-title">Complexity alert</div>
19694                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19695                    <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;" />
19696                  </div>
19697                  <div class="explainer-card prominent" style="margin:0;">
19698                    <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>
19699                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19700# 50  = flag any file with &gt; 50 branch points
19701# 100 = flag any file with &gt; 100 branch points
19702# Files above the threshold are highlighted
19703# in the result page metric strip.</div>
19704                  </div>
19705                </div>
19706                <div class="preset-inline-row">
19707                  <div class="toggle-card" style="margin:0;">
19708                    <div class="field-help-title">Git hotspots</div>
19709                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19710                    <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;" />
19711                  </div>
19712                  <div class="explainer-card prominent" style="margin:0;">
19713                    <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>
19714                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
19715# 30  = last month of activity
19716# 365 = last year
19717# 0   = disable the hotspots table
19718# Adds Commits + Last-changed columns to CSV.</div>
19719                  </div>
19720                </div>
19721                <div class="preset-inline-row">
19722                  <div class="toggle-card" style="margin:0;">
19723                    <div class="field-help-title">Duplicate handling</div>
19724                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19725                    <select name="exclude_duplicates" id="exclude_duplicates">
19726                      <option value="disabled" selected>Detect and report only (default)</option>
19727                      <option value="enabled">Detect and exclude from SLOC totals</option>
19728                    </select>
19729                  </div>
19730                  <div class="explainer-card prominent" style="margin:0;">
19731                    <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>
19732                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19733# detect only   → all 3 counted in SLOC
19734# exclude dupes → 1 counted, 2 excluded
19735# Duplicate groups chip always shows the count.</div>
19736                  </div>
19737                </div>
19738                <div class="always-tracked-tip" style="margin:8px 0 0;">
19739                  <div class="always-tracked-tip-icon">ℹ</div>
19740                  <div class="always-tracked-tip-body">
19741                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
19742                    <div class="always-tracked-metrics-row">
19743                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19744                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
19745                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
19746                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
19747                    </div>
19748                    <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>
19749                  </div>
19750                </div>
19751              </div>
19752
19753              <div class="wizard-actions">
19754                <div class="left">
19755                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19756                </div>
19757                <div class="right">
19758                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19759                </div>
19760              </div>
19761            </div>
19762
19763            <div class="wizard-step" data-step="3">
19764              <div class="section">
19765                <div class="section-kicker">Step 3</div>
19766                <h2>Output and report identity</h2>
19767                <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>
19768                <div class="preset-kv-row">
19769                  <div class="toggle-card" style="margin:0;">
19770                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19771                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19772                    <select id="scan_preset">
19773                      <option value="balanced">Balanced local scan</option>
19774                      <option value="code_focused">Code focused</option>
19775                      <option value="comment_audit">Comment audit</option>
19776                      <option value="deep_review">Deep review</option>
19777                    </select>
19778                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19779                  </div>
19780                  <div class="explainer-card">
19781                    <div class="field-help-title">Selected scan preset</div>
19782                    <div class="explainer-body" id="scan-preset-description"></div>
19783                    <div class="preset-summary-row" id="scan-preset-summary"></div>
19784                    <div class="code-sample" id="scan-preset-example"></div>
19785                    <div class="preset-note" id="scan-preset-note"></div>
19786                  </div>
19787                </div>
19788                <hr class="step3-separator" />
19789                <div class="preset-kv-row">
19790                  <div class="toggle-card" style="margin:0;">
19791                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19792                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19793                    <select id="artifact_preset">
19794                      <option value="review">Review bundle</option>
19795                      <option value="full">Full bundle</option>
19796                      <option value="html_only">HTML only</option>
19797                      <option value="machine">Machine bundle</option>
19798                    </select>
19799                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19800                  </div>
19801                  <div class="explainer-card">
19802                    <div class="field-help-title">Selected artifact preset</div>
19803                    <div class="explainer-body" id="artifact-preset-description"></div>
19804                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
19805                    <div class="code-sample" id="artifact-preset-example"></div>
19806                  </div>
19807                </div>
19808              </div>
19809
19810              <div class="section section-spacer-top">
19811                <div class="output-field-row">
19812                  <div class="field">
19813                    <label for="output_dir">Output directory</label>
19814                    {% if server_mode %}
19815                    <div class="input-group compact">
19816                      <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);" />
19817                    </div>
19818                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19819                    {% else %}
19820                    <div class="input-group compact">
19821                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19822                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19823                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
19824                    </div>
19825                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19826                    {% endif %}
19827                  </div>
19828                  <div class="output-field-aside">
19829                    <strong>Where reports land</strong>
19830                    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.
19831                  </div>
19832                </div>
19833              </div>
19834
19835              <div class="section section-spacer-top">
19836                <div class="output-field-row">
19837                  <div class="field">
19838                    <label for="report_title">Report title</label>
19839                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19840                    <div class="hint">Appears in HTML and PDF output headers.</div>
19841                  </div>
19842                  <div class="output-field-aside">
19843                    <strong>Shown in exported artifacts</strong>
19844                    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.
19845                  </div>
19846                </div>
19847              </div>
19848
19849              <div class="section section-spacer-top">
19850                <div class="output-field-row">
19851                  <div class="field">
19852                    <label for="report_header_footer">Report header / footer</label>
19853                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19854                    <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>
19855                  </div>
19856                  <div class="output-field-aside">
19857                    <strong>Page-level identification</strong>
19858                    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.
19859                  </div>
19860                </div>
19861              </div>
19862
19863              <div class="wizard-actions">
19864                <div class="left">
19865                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19866                </div>
19867                <div class="right">
19868                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19869                </div>
19870              </div>
19871            </div>
19872
19873            <div class="wizard-step" data-step="4">
19874              <div class="section">
19875                <div class="section-kicker">Step 4</div>
19876                <h2>Review selections and run</h2>
19877                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19878                <div class="review-grid">
19879                  <div class="review-card highlight">
19880                    <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>
19881                    <ul id="review-scan-summary"></ul>
19882                  </div>
19883                  <div class="review-card highlight">
19884                    <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>
19885                    <ul id="review-count-summary"></ul>
19886                  </div>
19887                  <div class="review-card">
19888                    <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>
19889                    <ul id="review-artifact-summary"></ul>
19890                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19891                  </div>
19892                  <div class="review-card">
19893                    <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>
19894                    <ul id="review-preview-summary"></ul>
19895                  </div>
19896                </div>
19897              </div>
19898
19899              <div class="wizard-actions">
19900                <div class="left">
19901                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19902                </div>
19903                <div class="right">
19904                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
19905                </div>
19906              </div>
19907            </div>
19908            {% if server_mode %}
19909            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19910            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19911            {% endif %}
19912          </form>
19913        </div>
19914      </section>
19915    </div>
19916  </div>
19917
19918  <script nonce="{{ csp_nonce }}">
19919    (function () {
19920      function startScanPhase() {
19921        var phaseEl = document.getElementById("scan-phase");
19922        if (!phaseEl) return;
19923        var phases = [
19924          "Discovering files...",
19925          "Decoding file encodings...",
19926          "Detecting languages...",
19927          "Analyzing source lines...",
19928          "Applying counting policies...",
19929          "Aggregating results...",
19930          "Rendering report..."
19931        ];
19932        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19933        var i = 0;
19934        function next() {
19935          phaseEl.style.opacity = "0";
19936          setTimeout(function () {
19937            phaseEl.textContent = phases[i];
19938            phaseEl.style.opacity = "0.85";
19939            var delay = durations[i] || 1800;
19940            i++;
19941            if (i < phases.length) { setTimeout(next, delay); }
19942          }, 200);
19943        }
19944        next();
19945      }
19946
19947      var form = document.getElementById("analyze-form");
19948      var loading = document.getElementById("loading");
19949      var submitButton = document.getElementById("submit-button");
19950      var pathInput = document.getElementById("path");
19951      var GIT_MODE = !!(pathInput && pathInput.readOnly);
19952      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19953      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19954      var outputDirInput = document.getElementById("output_dir");
19955      var reportTitleInput = document.getElementById("report_title");
19956      var previewPanel = document.getElementById("preview-panel");
19957      var refreshButton = document.getElementById("refresh-preview");
19958      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19959      var useSamplePath = document.getElementById("use-sample-path");
19960      var useDefaultOutput = document.getElementById("use-default-output");
19961      var browsePath = document.getElementById("browse-path");
19962      var browseOutputDir = document.getElementById("browse-output-dir");
19963      var browseCoverage = document.getElementById("browse-coverage");
19964      var coverageInput = document.getElementById("coverage_file");
19965      var covScanStatus = document.getElementById("cov-scan-status");
19966      var coverageSuggestTimer = null;
19967      var covAutoFilled = false;
19968      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19969
19970      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19971      (function() {
19972        var ids = ["path", "output_dir"];
19973        ids.forEach(function(id) {
19974          var el = document.getElementById(id);
19975          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19976        });
19977      }());
19978      function fmtBytes(b) {
19979        b = Number(b) || 0;
19980        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19981        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19982        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
19983        return b + ' B';
19984      }
19985      var themeToggle = document.getElementById("theme-toggle");
19986
19987      function showBannerToast(msg, isError, opts) {
19988        opts = opts || {};
19989        var t = document.createElement('div');
19990        t.className = isError ? 'toast-error' : 'toast-success';
19991        var topPos = opts.top ? '80px' : null;
19992        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19993          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19994          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19995          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19996        if (opts.icon) {
19997          var inner = document.createElement('span');
19998          inner.innerHTML = opts.icon + ' ';
19999          t.appendChild(inner);
20000        }
20001        t.appendChild(document.createTextNode(msg));
20002        document.body.appendChild(t);
20003        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
20004      }
20005      var mixedLinePolicy = document.getElementById("mixed_line_policy");
20006      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
20007      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
20008      var scanPreset = document.getElementById("scan_preset");
20009      var artifactPreset = document.getElementById("artifact_preset");
20010      var includeGlobsInput = document.getElementById("include_globs");
20011      var excludeGlobsInput = document.getElementById("exclude_globs");
20012
20013      // Include globs scope badge — updates reactively as the user types.
20014      (function() {
20015        var badge = document.getElementById("include-scope-badge");
20016        if (!badge || !includeGlobsInput) return;
20017        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> ';
20018        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> ';
20019        function update() {
20020          var val = includeGlobsInput.value.trim();
20021          if (!val) {
20022            badge.className = "include-scope-badge scope-all";
20023            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
20024          } else {
20025            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
20026            badge.className = "include-scope-badge scope-narrow";
20027            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
20028          }
20029        }
20030        includeGlobsInput.addEventListener("input", update);
20031        update();
20032      }());
20033
20034      // Quick-exclude chips — append pattern to exclude_globs textarea.
20035      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
20036        chip.addEventListener("click", function() {
20037          var pattern = chip.getAttribute("data-pattern") || "";
20038          if (!pattern || !excludeGlobsInput) return;
20039          var current = excludeGlobsInput.value.trim();
20040          // For the "skip all" chip, replace any existing dep patterns cleanly.
20041          var patterns = pattern.split("\n");
20042          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
20043          var added = false;
20044          patterns.forEach(function(p) {
20045            p = p.trim();
20046            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
20047          });
20048          if (added) {
20049            excludeGlobsInput.value = lines.join("\n");
20050            excludeGlobsInput.dispatchEvent(new Event("input"));
20051          }
20052          chip.classList.add("active");
20053        });
20054      });
20055
20056      var liveReportTitle = document.getElementById("live-report-title");
20057      var navProjectPill = document.getElementById("nav-project-pill");
20058      var navProjectTitle = document.getElementById("nav-project-title");
20059      var reportTitlePreview = null;
20060      var wizardProgressFill = document.getElementById("wizard-progress-fill");
20061      var wizardProgressValue = document.getElementById("wizard-progress-value");
20062      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
20063      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
20064      var reportTitleTouched = false;
20065      var currentStep = 1;
20066      var previewTimer = null;
20067      var _previewGen = 0;
20068      // True while the scope preview (local) / project upload (server mode) is in
20069      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
20070      // user can't advance past a project whose scope/upload isn't ready yet.
20071      var previewLoading = false;
20072      // Set when the current preview reports multiple independent git repos under
20073      // the selected root. Advancing past step 1 is blocked until the user ticks
20074      // the acknowledgement checkbox (or re-selects a single repository).
20075      var multiRepoBlocked = false;
20076      function step1ForwardBlocked() {
20077        return previewLoading || multiRepoBlocked;
20078      }
20079      function refreshStep1Gate() {
20080        var nextBtn = document.getElementById("step1-next");
20081        if (nextBtn) {
20082          var blocked = step1ForwardBlocked();
20083          nextBtn.classList.toggle("is-blocked", blocked);
20084          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
20085        }
20086      }
20087      function setPreviewLoading(loading) {
20088        previewLoading = !!loading;
20089        var gate = document.getElementById("preview-gate-status");
20090        refreshStep1Gate();
20091        if (gate) {
20092          var txt = gate.querySelector(".preview-gate-text");
20093          if (txt) txt.textContent = SERVER_MODE
20094            ? "Uploading & scanning project…"
20095            : "Scanning project scope…";
20096          gate.style.display = previewLoading ? "flex" : "none";
20097        }
20098      }
20099      // Info button on the gate: scroll up to the live scope preview so the user
20100      // can see exactly what is being scanned (elapsed time + rotating status).
20101      var previewGateInfo = document.getElementById("preview-gate-info");
20102      if (previewGateInfo) {
20103        previewGateInfo.addEventListener("click", function () {
20104          var target = document.getElementById("preview-panel");
20105          if (!target) return;
20106          target.scrollIntoView({ behavior: "smooth", block: "center" });
20107          target.classList.add("preview-panel-flash");
20108          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
20109        });
20110      }
20111      var quickScanBtn = document.getElementById("quick-scan-btn");
20112
20113      function dismissAnalysisModal() {
20114        if (loading) loading.classList.remove("active");
20115        document.body.classList.remove("modal-open");
20116        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20117          var el = document.getElementById(id);
20118          if (el) el.classList.add("hidden");
20119        });
20120        var cancelBtn = document.getElementById("lc-cancel-btn");
20121        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
20122        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
20123        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
20124        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
20125        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");}
20126        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
20127        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
20128        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20129        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20130        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20131        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20132      }
20133
20134      var lcDismissBtn = document.getElementById("lc-dismiss");
20135      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
20136
20137      // When the browser restores this page from bfcache (Back button after navigating to results),
20138      // the loading overlay would still be showing its active state. Dismiss it immediately.
20139      window.addEventListener("pageshow", function(e) {
20140        if (e.persisted) { dismissAnalysisModal(); }
20141      });
20142
20143      function startAsyncAnalysis(formData) {
20144        var gitRepo = (formData.get("git_repo") || "").toString();
20145        var gitRef  = (formData.get("git_ref")  || "").toString();
20146        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
20147        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
20148
20149        var pathEl = document.getElementById("lc-path-text");
20150        if (pathEl) pathEl.textContent = displayPath;
20151
20152        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
20153          var el = document.getElementById(id);
20154          if (el) el.classList.add("hidden");
20155        });
20156        var cancelBtn = document.getElementById("lc-cancel-btn");
20157        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
20158        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
20159        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
20160        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
20161        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
20162        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
20163        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
20164        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");}
20165        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
20166
20167        if (loading) loading.classList.add("active");
20168        document.body.classList.add("modal-open");
20169
20170        var startTime = Date.now();
20171        var elapsedTimer = setInterval(function() {
20172          var s = Math.floor((Date.now() - startTime) / 1000);
20173          var el = document.getElementById("lc-elapsed");
20174          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
20175        }, 1000);
20176
20177        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
20178
20179        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();}
20180
20181        var PHASE_DESC = {
20182          'Starting': 'Initializing language analyzers and loading configuration\u2026',
20183          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
20184          'Running': 'Running the lexical state machine across all discovered source files\u2026',
20185          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
20186          'Done': 'Analysis complete \u2014 loading your results\u2026',
20187          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
20188        };
20189        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
20190        function lcSetPhase(txt) {
20191          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
20192          var desc = document.getElementById("lc-stage-desc");
20193          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
20194          var step = PHASE_STEP[txt] || 1;
20195          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");}
20196        }
20197
20198        function lcShowCancelled() {
20199          clearInterval(elapsedTimer);
20200          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
20201          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
20202          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
20203          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
20204          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
20205          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
20206          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
20207          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
20208          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20209          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20210        }
20211
20212        var lcCancelBtn = document.getElementById("lc-cancel-btn");
20213        if (lcCancelBtn) {
20214          lcCancelBtn.onclick = function() {
20215            if (!activeWaitId) { dismissAnalysisModal(); return; }
20216            lcCancelBtn.disabled = true;
20217            lcCancelBtn.textContent = "Cancelling\u2026";
20218            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
20219              .then(function() { lcShowCancelled(); })
20220              .catch(function() { lcShowCancelled(); });
20221          };
20222        }
20223
20224        function lcShowError(msg) {
20225          clearInterval(elapsedTimer);
20226          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
20227          lcSetPhase("Failed");
20228          var msgEl = document.getElementById("lc-err-msg");
20229          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
20230          var errEl = document.getElementById("lc-err");
20231          var actEl = document.getElementById("lc-actions");
20232          if (errEl) errEl.classList.remove("hidden");
20233          if (actEl) actEl.classList.remove("hidden");
20234          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
20235          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
20236        }
20237
20238        function lcPoll(waitId) {
20239          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
20240            .then(function(r) {
20241              if (!r.ok) throw new Error("HTTP " + r.status);
20242              return r.json();
20243            })
20244            .then(function(data) {
20245              pollRetries = 0;
20246              if (data.state === "complete") {
20247                clearInterval(elapsedTimer);
20248                lcSetPhase("Done");
20249                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
20250              } else if (data.state === "failed") {
20251                lcShowError(data.message);
20252              } else if (data.state === "cancelled") {
20253                lcShowCancelled();
20254              } else {
20255                var s = Math.floor((Date.now() - startTime) / 1000);
20256                if (s > 90 && !warnShown) {
20257                  warnShown = true;
20258                  var w = document.getElementById("lc-warn");
20259                  if (w) w.classList.remove("hidden");
20260                }
20261                lcSetPhase(data.phase || "Running");
20262                var fd = data.files_done || 0, ft = data.files_total || 0;
20263                if (ft > 0) {
20264                  var card = document.getElementById("lc-files-card");
20265                  if (card) card.classList.remove("hidden");
20266                  var el = document.getElementById("lc-files");
20267                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
20268                  var now = Date.now();
20269                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
20270                  if (fdelta > 0 && tdelta > 0.4) {
20271                    var fps = Math.round(fdelta / tdelta);
20272                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
20273                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
20274                  }
20275                  lastFd = fd; lastFdTime = now;
20276                }
20277                setTimeout(function() { lcPoll(waitId); }, 1500);
20278              }
20279            })
20280            .catch(function() {
20281              pollRetries++;
20282              if (pollRetries >= 5) {
20283                lcShowError("Lost connection to server. Reload to check status.");
20284              } else {
20285                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
20286              }
20287            });
20288        }
20289
20290        var params = new URLSearchParams(formData);
20291        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
20292          .then(function(r) {
20293            var waitId = r.headers.get("x-wait-id");
20294            if (!waitId) { window.location.href = "/scan"; return; }
20295            activeWaitId = waitId;
20296            setTimeout(function() { lcPoll(waitId); }, 1500);
20297          })
20298          .catch(function(err) {
20299            lcShowError("Could not reach server: " + (err.message || err));
20300          });
20301      }
20302
20303      if (quickScanBtn) {
20304        quickScanBtn.addEventListener("click", function () {
20305          var pathVal = pathInput ? pathInput.value.trim() : "";
20306          if (!pathVal) {
20307            alert("Please enter or browse to a project path first.");
20308            return;
20309          }
20310          quickScanBtn.disabled = true;
20311          quickScanBtn.textContent = "Scanning...";
20312          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
20313          startAsyncAnalysis(new FormData(form));
20314        });
20315      }
20316
20317      var mixedPolicyInfo = {
20318        code_only: {
20319          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.",
20320          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'
20321        },
20322        code_and_comment: {
20323          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.",
20324          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'
20325        },
20326        comment_only: {
20327          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.",
20328          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'
20329        },
20330        separate_mixed_category: {
20331          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.",
20332          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'
20333        }
20334      };
20335
20336      var scanPresetInfo = {
20337        balanced: {
20338          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.",
20339          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20340          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20341          note: "Best when you want a stable local overview before making deeper adjustments.",
20342          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20343        },
20344        code_focused: {
20345          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20346          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20347          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20348          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20349          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20350        },
20351        comment_audit: {
20352          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20353          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20354          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20355          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20356          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20357        },
20358        deep_review: {
20359          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20360          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20361          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20362          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20363          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20364        }
20365      };
20366
20367      var artifactPresetInfo = {
20368        review: {
20369          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20370          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20371          example: "Ideal for a quick local review before sharing results."
20372        },
20373        full: {
20374          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20375          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20376          example: "Use when producing a deliverable or storing a snapshot for future comparison."
20377        },
20378        html_only: {
20379          description: "Standalone HTML report only. No PDF generation, no data files.",
20380          chips: ["HTML only"],
20381          example: "Fastest option when you only need to open the report in a browser."
20382        },
20383        machine: {
20384          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20385          chips: ["JSON", "CSV", "no HTML", "no PDF"],
20386          example: "Use in CI to capture metrics without generating visual reports."
20387        }
20388      };
20389
20390      function applyArtifactPreset() {
20391        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20392        if (!info) return;
20393        var descEl = document.getElementById("artifact-preset-description");
20394        var exampleEl = document.getElementById("artifact-preset-example");
20395        if (descEl) descEl.textContent = info.description;
20396        if (exampleEl) exampleEl.textContent = info.example;
20397        renderPresetChips("artifact-preset-summary", info.chips);
20398      }
20399
20400      function applyTheme(theme) {
20401        if (theme === "dark") document.body.classList.add("dark-theme");
20402        else document.body.classList.remove("dark-theme");
20403      }
20404
20405      function loadSavedTheme() {
20406        var saved = null;
20407        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20408        applyTheme(saved === "dark" ? "dark" : "light");
20409      }
20410
20411      function updateScrollProgress() {
20412        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20413        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20414        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
20415        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20416        var step = Math.min(Math.max(currentStep, 1), 4);
20417        var base = stepBase[step];
20418        var end  = stepEnd[step];
20419
20420        var scrollFrac = 0;
20421        var activePanel = document.querySelector(".wizard-step.active");
20422        if (activePanel) {
20423          var scrollTop = window.scrollY || window.pageYOffset || 0;
20424          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20425          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20426          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20427          var scrolled = scrollTop + viewH - panelTop;
20428          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20429        }
20430
20431        var percent = Math.round(base + (end - base) * scrollFrac);
20432        percent = Math.min(end, Math.max(base, percent));
20433        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20434        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20435      }
20436
20437      function updateWizardProgress() {
20438        updateScrollProgress();
20439      }
20440
20441      var stepDescriptions = [
20442        "Choose a project folder, apply scope filters, and preview which files will be counted.",
20443        "Configure how mixed code-plus-comment lines and docstrings are classified.",
20444        "Pick your output formats, scan preset, and where reports are saved.",
20445        "Review all settings and launch the analysis."
20446      ];
20447
20448      function updateStepNav(step) {
20449        var infoLabel = document.getElementById("step-nav-info-label");
20450        var infoDesc  = document.getElementById("step-nav-info-desc");
20451        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20452        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
20453      }
20454
20455      function updateSidebarSummary() {
20456        var sumPath    = document.getElementById("sum-path");
20457        var sumPreset  = document.getElementById("sum-preset");
20458        var sumOutput  = document.getElementById("sum-output");
20459        var sidebarSummary = document.getElementById("sidebar-summary");
20460        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20461        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
20462        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20463        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
20464        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20465        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20466        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20467      }
20468
20469      function setStep(step, pushHistory) {
20470        currentStep = step;
20471        stepPanels.forEach(function (panel) {
20472          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20473        });
20474        stepButtons.forEach(function (button) {
20475          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20476        });
20477        var layoutEl = document.querySelector(".layout");
20478        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20479        updateWizardProgress();
20480        updateStepNav(step);
20481        stepButtons.forEach(function(btn) {
20482          var t = Number(btn.getAttribute("data-step-target"));
20483          btn.classList.toggle("done", t < step);
20484        });
20485        updateSidebarSummary();
20486
20487        if (pushHistory !== false) {
20488          try {
20489            history.pushState({ wizardStep: step }, "", "#step" + step);
20490          } catch (e) {}
20491        }
20492
20493        window.scrollTo({ top: 0, behavior: "instant" });
20494      }
20495
20496      window.addEventListener("popstate", function (e) {
20497        if (e.state && e.state.wizardStep) {
20498          setStep(e.state.wizardStep, false);
20499        } else {
20500          var hashMatch = location.hash.match(/^#step([1-4])$/);
20501          if (hashMatch) setStep(Number(hashMatch[1]), false);
20502        }
20503      });
20504
20505      function inferTitleFromPath(value) {
20506        if (!value) return "project";
20507        var cleaned = value.replace(/[\/\\]+$/, "");
20508        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20509        return parts.length ? parts[parts.length - 1] : value;
20510      }
20511
20512      function updateReportTitleFromPath() {
20513        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20514        if (!reportTitleTouched) {
20515          reportTitleInput.value = inferred;
20516        }
20517        var title = reportTitleInput.value || inferred;
20518        if (liveReportTitle) liveReportTitle.textContent = title;
20519        if (reportTitlePreview) reportTitlePreview.textContent = title;
20520        document.title = "OxideSLOC | " + title;
20521
20522        var projectPath = (pathInput.value || "").trim();
20523        if (navProjectPill && navProjectTitle) {
20524          if (projectPath.length > 0) {
20525            navProjectTitle.textContent = inferred;
20526            navProjectPill.classList.add("visible");
20527          } else {
20528            navProjectTitle.textContent = "";
20529            navProjectPill.classList.remove("visible");
20530          }
20531        }
20532      }
20533
20534      function updateMixedPolicyUI() {
20535        var key = mixedLinePolicy.value || "code_only";
20536        var info = mixedPolicyInfo[key];
20537        document.getElementById("mixed-policy-description").textContent = info.description;
20538        document.getElementById("mixed-policy-example").textContent = info.example;
20539      }
20540
20541      function updatePythonDocstringUI() {
20542        var checked = !!pythonDocstrings.checked;
20543        document.getElementById("python-docstring-example").textContent = checked
20544          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
20545          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
20546        document.getElementById("python-docstring-live-help").textContent = checked
20547          ? "Enabled: docstrings contribute to comment-style totals."
20548          : "Disabled: docstrings are not counted as comment content.";
20549      }
20550
20551      function renderPresetChips(targetId, chips) {
20552        var target = document.getElementById(targetId);
20553        if (!target) return;
20554        target.innerHTML = (chips || []).map(function (chip) {
20555          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20556        }).join('');
20557      }
20558
20559      function updatePresetDescriptions() {
20560        var scanInfo = scanPresetInfo[scanPreset.value];
20561        if (!scanInfo) return;
20562        document.getElementById("scan-preset-description").textContent = scanInfo.description;
20563        document.getElementById("scan-preset-example").textContent = scanInfo.example;
20564        document.getElementById("scan-preset-note").textContent = scanInfo.note;
20565        renderPresetChips("scan-preset-summary", scanInfo.chips);
20566      }
20567
20568      function applyScanPreset() {
20569        var info = scanPresetInfo[scanPreset.value];
20570        if (!info || !info.apply) return;
20571        mixedLinePolicy.value = info.apply.mixed;
20572        pythonDocstrings.checked = !!info.apply.docstrings;
20573        document.getElementById("generated_file_detection").value = info.apply.generated;
20574        document.getElementById("minified_file_detection").value = info.apply.minified;
20575        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20576        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20577        document.getElementById("binary_file_behavior").value = info.apply.binary;
20578        updateMixedPolicyUI();
20579        updatePythonDocstringUI();
20580      }
20581
20582      function updateReview() {
20583        var scanSummary = document.getElementById("review-scan-summary");
20584        var countSummary = document.getElementById("review-count-summary");
20585        var artifactSummary = document.getElementById("review-artifact-summary");
20586        var outputSummary = document.getElementById("review-output-summary");
20587        var previewSummary = document.getElementById("review-preview-summary");
20588        var readinessSummary = document.getElementById("review-readiness-summary");
20589        var includeText = document.getElementById("include_globs").value.trim();
20590        var excludeText = document.getElementById("exclude_globs").value.trim();
20591        var sidePathPreview = document.getElementById("side-path-preview");
20592        var sideOutputPreview = document.getElementById("side-output-preview");
20593        var sideTitlePreview = document.getElementById("side-title-preview");
20594
20595        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20596        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20597        if (sideTitlePreview) {
20598          var rt = document.getElementById("report_title");
20599          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20600        }
20601
20602        scanSummary.innerHTML = ""
20603          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20604          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20605          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20606
20607        countSummary.innerHTML = ""
20608          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20609          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20610          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20611          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20612          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20613          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20614          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20615          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20616
20617        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20618
20619        outputSummary.innerHTML = ""
20620          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20621          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20622
20623        if (previewSummary) {
20624          if (GIT_MODE) {
20625            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>';
20626          } else {
20627          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20628          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20629          var statMap = {};
20630          statButtons.forEach(function (button) {
20631            var valueNode = button.querySelector('.scope-stat-value');
20632            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20633          });
20634          previewSummary.innerHTML = ''
20635            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20636            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20637            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20638            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20639            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20640            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20641
20642          if (readinessSummary) {
20643            readinessSummary.innerHTML = ''
20644              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20645              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20646              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20647          }
20648          } // end else (non-GIT_MODE)
20649        }
20650      }
20651
20652      function escapeHtml(value) {
20653        return String(value)
20654          .replace(/&/g, "&amp;")
20655          .replace(/</g, "&lt;")
20656          .replace(/>/g, "&gt;")
20657          .replace(/"/g, "&quot;")
20658          .replace(/'/g, "&#39;");
20659      }
20660
20661      function isPythonVisible() {
20662        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20663      }
20664
20665      function syncPythonVisibility() {
20666        var html = previewPanel.textContent || "";
20667        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20668        pythonWraps.forEach(function (node) {
20669          node.classList.toggle("hidden", !hasPython);
20670        });
20671      }
20672
20673      function attachPreviewInteractions() {
20674        // Multiple-repository caution banner: gate step 1 until acknowledged, and
20675        // let each listed repo be picked as the scan root with one click.
20676        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20677        if (multiRepoBanner) {
20678          multiRepoBlocked = true;
20679          refreshStep1Gate();
20680          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20681          if (ackBox) {
20682            ackBox.addEventListener("change", function () {
20683              multiRepoBlocked = !ackBox.checked;
20684              refreshStep1Gate();
20685            });
20686          }
20687          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20688          repoButtons.forEach(function (btn) {
20689            btn.addEventListener("click", function () {
20690              var repoPath = btn.getAttribute("data-repo-path") || "";
20691              if (!repoPath || !pathInput) return;
20692              pathInput.value = repoPath;
20693              scrollInputToEnd(pathInput);
20694              updateReportTitleFromPath();
20695              autoSetOutputDir(repoPath);
20696              fetchProjectHistory(repoPath);
20697              loadPreview();
20698              updateReview();
20699            });
20700          });
20701        }
20702        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20703        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20704        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20705        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20706        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20707        var searchInput = previewPanel.querySelector("#explorer-search");
20708        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20709        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20710        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20711        var activeFilter = "all";
20712        var activeLanguage = "";
20713        var searchTerm = "";
20714        var currentSortKey = null;
20715        var currentSortOrder = "asc";
20716        var childRows = {};
20717
20718        rows.forEach(function (row) {
20719          var parentId = row.getAttribute("data-parent-id") || "";
20720          var rowId = row.getAttribute("data-row-id") || "";
20721          if (!childRows[parentId]) childRows[parentId] = [];
20722          childRows[parentId].push(rowId);
20723        });
20724
20725        function rowById(id) {
20726          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20727        }
20728
20729        function hasCollapsedAncestor(row) {
20730          var parentId = row.getAttribute("data-parent-id");
20731          while (parentId) {
20732            var parent = rowById(parentId);
20733            if (!parent) break;
20734            if (parent.getAttribute("data-expanded") === "false") return true;
20735            parentId = parent.getAttribute("data-parent-id");
20736          }
20737          return false;
20738        }
20739
20740        function updateToggleGlyph(row) {
20741          var toggle = row.querySelector(".tree-toggle");
20742          if (!toggle) return;
20743          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20744        }
20745
20746        function rowSortValue(row, key) {
20747          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20748        }
20749
20750        function updateSortButtons() {
20751          sortButtons.forEach(function (button) {
20752            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20753            var indicator = button.querySelector(".tree-sort-indicator");
20754            button.classList.toggle("active", isActive);
20755            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20756            if (indicator) {
20757              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20758            }
20759          });
20760        }
20761
20762        function sortSiblingRows() {
20763          if (!treeContainer) {
20764            updateSortButtons();
20765            return;
20766          }
20767
20768          var rowMap = {};
20769          var childrenMap = {};
20770          rows.forEach(function (row) {
20771            var rowId = row.getAttribute("data-row-id");
20772            var parentId = row.getAttribute("data-parent-id") || "";
20773            rowMap[rowId] = row;
20774            if (!childrenMap[parentId]) childrenMap[parentId] = [];
20775            childrenMap[parentId].push(rowId);
20776          });
20777
20778          Object.keys(childrenMap).forEach(function (parentId) {
20779            if (!parentId) return;
20780            childrenMap[parentId].sort(function (a, b) {
20781              var rowA = rowMap[a];
20782              var rowB = rowMap[b];
20783              if (!currentSortKey) {
20784                return Number(a) - Number(b);
20785              }
20786              var valueA = rowSortValue(rowA, currentSortKey);
20787              var valueB = rowSortValue(rowB, currentSortKey);
20788              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20789              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20790              var fallbackA = rowSortValue(rowA, "name");
20791              var fallbackB = rowSortValue(rowB, "name");
20792              if (fallbackA < fallbackB) return -1;
20793              if (fallbackA > fallbackB) return 1;
20794              return Number(a) - Number(b);
20795            });
20796          });
20797
20798          var orderedIds = [];
20799          function pushChildren(parentId) {
20800            (childrenMap[parentId] || []).forEach(function (childId) {
20801              orderedIds.push(childId);
20802              pushChildren(childId);
20803            });
20804          }
20805
20806          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20807            orderedIds.push(topId);
20808            pushChildren(topId);
20809          });
20810
20811          orderedIds.forEach(function (id) {
20812            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20813          });
20814          updateSortButtons();
20815        }
20816
20817        function updateLanguageButtons() {
20818          languageButtons.forEach(function (button) {
20819            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20820            var isActive = languageValue === activeLanguage;
20821            button.classList.toggle("active", isActive);
20822          });
20823        }
20824
20825        function rowSelfMatches(row) {
20826          var kind = row.getAttribute("data-kind");
20827          var status = row.getAttribute("data-status");
20828          var language = (row.getAttribute("data-language") || "").toLowerCase();
20829          var name = row.getAttribute("data-name-lower") || "";
20830          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20831          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20832          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20833          var passesLanguage = !activeLanguage || language === activeLanguage;
20834          return passesFilter && passesSearch && passesLanguage;
20835        }
20836
20837        function hasMatchingDescendant(rowId) {
20838          return (childRows[rowId] || []).some(function (childId) {
20839            var childRow = rowById(childId);
20840            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20841          });
20842        }
20843
20844        function rowMatches(row) {
20845          if (rowSelfMatches(row)) return true;
20846          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20847        }
20848
20849        function resetViewState() {
20850          activeFilter = "all";
20851          activeLanguage = "";
20852          searchTerm = "";
20853          currentSortKey = null;
20854          currentSortOrder = "asc";
20855          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20856          if (searchInput) searchInput.value = "";
20857          if (filterSelect) filterSelect.value = "all";
20858          updateLanguageButtons();
20859        }
20860
20861        function applyVisibility() {
20862          rows.forEach(function (row) {
20863            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20864            row.classList.toggle("hidden-by-filter", !visible);
20865            row.style.display = visible ? "grid" : "none";
20866          });
20867          buttons.forEach(function (button) {
20868            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20869          });
20870          if (filterSelect) filterSelect.value = activeFilter;
20871        }
20872
20873        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20874        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20875        var originalStats = {};
20876        buttons.forEach(function (btn) {
20877          var f = btn.getAttribute('data-filter');
20878          var v = btn.querySelector('.scope-stat-value');
20879          if (f && v) originalStats[f] = v.textContent;
20880        });
20881
20882        function applySubmoduleStats(statsJson) {
20883          try {
20884            var s = JSON.parse(statsJson);
20885            buttons.forEach(function (btn) {
20886              var f = btn.getAttribute('data-filter');
20887              var v = btn.querySelector('.scope-stat-value');
20888              if (!v) return;
20889              if (f === 'dir') v.textContent = s.dirs;
20890              else if (f === 'file') v.textContent = s.files;
20891              else if (f === 'supported') v.textContent = s.supported;
20892              else if (f === 'skipped') v.textContent = s.skipped;
20893              else if (f === 'unsupported') v.textContent = s.unsupported;
20894            });
20895          } catch (e) {}
20896        }
20897
20898        function restoreBaseRepoStats() {
20899          buttons.forEach(function (btn) {
20900            var f = btn.getAttribute('data-filter');
20901            var v = btn.querySelector('.scope-stat-value');
20902            if (v && originalStats[f]) v.textContent = originalStats[f];
20903          });
20904          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20905          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20906        }
20907
20908        submoduleChips.forEach(function (chip) {
20909          chip.addEventListener('click', function () {
20910            var statsJson = chip.getAttribute('data-sub-stats');
20911            if (!statsJson) return;
20912            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20913            chip.classList.add('active');
20914            applySubmoduleStats(statsJson);
20915            if (baseRepoBtn) baseRepoBtn.style.display = '';
20916          });
20917        });
20918
20919        if (baseRepoBtn) {
20920          baseRepoBtn.addEventListener('click', function () {
20921            restoreBaseRepoStats();
20922            resetViewState();
20923            sortSiblingRows();
20924            applyVisibility();
20925          });
20926        }
20927
20928        buttons.forEach(function (button) {
20929          button.addEventListener("click", function () {
20930            var filterValue = button.getAttribute("data-filter") || "all";
20931            if (filterValue === "reset-view") {
20932              restoreBaseRepoStats();
20933              resetViewState();
20934              sortSiblingRows();
20935              applyVisibility();
20936              return;
20937            }
20938            activeFilter = filterValue;
20939            applyVisibility();
20940          });
20941        });
20942
20943        rows.forEach(function (row) {
20944          updateToggleGlyph(row);
20945          var toggle = row.querySelector(".tree-toggle");
20946          if (toggle) {
20947            toggle.addEventListener("click", function () {
20948              var expanded = row.getAttribute("data-expanded") !== "false";
20949              row.setAttribute("data-expanded", expanded ? "false" : "true");
20950              updateToggleGlyph(row);
20951              applyVisibility();
20952            });
20953          }
20954        });
20955
20956        actionButtons.forEach(function (button) {
20957          button.addEventListener("click", function () {
20958            var action = button.getAttribute("data-explorer-action");
20959            if (action === "expand-all") {
20960              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20961            } else if (action === "collapse-all") {
20962              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20963            } else if (action === "clear-filters") {
20964              resetViewState();
20965            }
20966            sortSiblingRows();
20967            applyVisibility();
20968          });
20969        });
20970
20971        if (filterSelect) {
20972          filterSelect.addEventListener("change", function () {
20973            activeFilter = filterSelect.value || "all";
20974            applyVisibility();
20975          });
20976        }
20977
20978        languageButtons.forEach(function (button) {
20979          button.addEventListener("click", function () {
20980            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20981            updateLanguageButtons();
20982            applyVisibility();
20983          });
20984        });
20985
20986        sortButtons.forEach(function (button) {
20987          button.addEventListener("click", function () {
20988            var sortKey = button.getAttribute("data-sort-key");
20989            if (currentSortKey === sortKey) {
20990              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20991            } else {
20992              currentSortKey = sortKey;
20993              currentSortOrder = "asc";
20994            }
20995            sortSiblingRows();
20996            applyVisibility();
20997          });
20998        });
20999
21000        if (searchInput) {
21001          searchInput.addEventListener("input", function () {
21002            searchTerm = searchInput.value.trim().toLowerCase();
21003            applyVisibility();
21004          });
21005        }
21006
21007        updateLanguageButtons();
21008        sortSiblingRows();
21009        applyVisibility();
21010      }
21011
21012      function loadPreview() {
21013        if (!previewPanel || !pathInput) return;
21014        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
21015        multiRepoBlocked = false;
21016        refreshStep1Gate();
21017        if (GIT_MODE) {
21018          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>';
21019          setPreviewLoading(false);
21020          return;
21021        }
21022        var path = pathInput.value.trim();
21023        var zeroWarn = document.getElementById('zero-files-warning');
21024        if (!path) {
21025          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
21026          if (zeroWarn) zeroWarn.style.display = 'none';
21027          setPreviewLoading(false);
21028          return;
21029        }
21030        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
21031        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
21032        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
21033        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
21034        var myGen = ++_previewGen;
21035        var _prevMsgs = [
21036          'Scanning directory structure\u2026',
21037          'Detecting file types\u2026',
21038          'Applying include / exclude filters\u2026',
21039          'Estimating file counts\u2026',
21040          'Building scope preview\u2026',
21041          'Almost there\u2026'
21042        ];
21043        var _prevMsgIdx = 0;
21044        var _prevStart = Date.now();
21045        previewPanel.innerHTML =
21046          '<div class="preview-loading">' +
21047          '<div class="preview-spinner"></div>' +
21048          '<div class="preview-loading-text">' +
21049          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
21050          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
21051          '</div></div>';
21052        var _sizeTextEl = document.getElementById('project-size-text');
21053        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
21054        window._previewInterval = setInterval(function() {
21055          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
21056          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
21057          var ml = document.getElementById('plm');
21058          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
21059        }, 1500);
21060        window._previewElapsedTimer = setInterval(function() {
21061          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
21062          var el = document.getElementById('ple');
21063          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
21064        }, 1000);
21065        setPreviewLoading(true);
21066        var previewUrl = "/preview?path=" + encodeURIComponent(path)
21067          + "&include_globs=" + encodeURIComponent(includeValue)
21068          + "&exclude_globs=" + encodeURIComponent(excludeValue);
21069        fetch(previewUrl)
21070          .then(function (response) { return response.text(); })
21071          .then(function (html) {
21072            if (myGen !== _previewGen) return;
21073            clearInterval(window._previewInterval); window._previewInterval = null;
21074            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
21075            setPreviewLoading(false);
21076            previewPanel.innerHTML = html;
21077            attachPreviewInteractions();
21078            syncPythonVisibility();
21079            updateReview();
21080            setTimeout(collapseLanguagePills, 50);
21081            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
21082            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
21083            var sizeText = document.getElementById('project-size-text');
21084            var sizeBtn = document.getElementById('project-size-btn');
21085            // In server mode with upload sizes available, keep the compressed/original pair.
21086            if (SERVER_MODE && window._lastUploadSizes) {
21087              var us = window._lastUploadSizes;
21088              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
21089                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
21090              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
21091                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
21092            } else if (sizeText && projectSize) {
21093              sizeText.textContent = 'Project size: ' + projectSize;
21094              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
21095            } else if (sizeText) {
21096              sizeText.textContent = 'Project size: \u2014';
21097            }
21098            if (zeroWarn) {
21099              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
21100              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
21101              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
21102              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
21103              if (supportedCount === 0 && fileCount > 0) {
21104                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).';
21105                zeroWarn.style.display = '';
21106              } else {
21107                zeroWarn.style.display = 'none';
21108              }
21109            }
21110          })
21111          .catch(function (err) {
21112            if (myGen !== _previewGen) return;
21113            clearInterval(window._previewInterval); window._previewInterval = null;
21114            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
21115            setPreviewLoading(false);
21116            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
21117          });
21118      }
21119
21120      function pickDirectory(targetInput, kind) {
21121        if (!targetInput) {
21122          showBannerToast("Directory picker: input element not found.", true);
21123          return;
21124        }
21125        if (SERVER_MODE) {
21126          if (kind === 'output') {
21127            showBannerToast(
21128              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
21129              false,
21130              { top: true, icon: '\u{1F4C1}' }
21131            );
21132            return;
21133          }
21134          var inputEl = kind === 'coverage'
21135            ? document.getElementById('cov-upload-input')
21136            : document.getElementById('dir-upload-input');
21137          if (!inputEl) return;
21138          inputEl.onchange = function () {
21139            var files = inputEl.files;
21140            if (!files || files.length === 0) return;
21141            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
21142            if (browseBtn) browseBtn.disabled = true;
21143
21144            function fileToBase64(file) {
21145              return new Promise(function (resolve, reject) {
21146                var reader = new FileReader();
21147                reader.onload = function () {
21148                  var b64 = reader.result.split(',')[1];
21149                  resolve(b64);
21150                };
21151                reader.onerror = reject;
21152                reader.readAsDataURL(file);
21153              });
21154            }
21155
21156            if (kind === 'coverage') {
21157              var f = files[0];
21158              if (previewPanel && targetInput === pathInput)
21159                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
21160              fileToBase64(f).then(function (b64) {
21161                return fetch('/api/upload-file', {
21162                  method: 'POST',
21163                  headers: { 'Content-Type': 'application/json' },
21164                  body: JSON.stringify({ filename: f.name, content: b64 })
21165                }).then(function (r) { return r.json(); });
21166              })
21167                .then(function (d) {
21168                  if (d && d.tmp_path) {
21169                    if (coverageInput) coverageInput.value = d.tmp_path;
21170                    setCovStatus('idle');
21171                  } else if (d && d.error) { showBannerToast(d.error, true); }
21172                })
21173                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
21174                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
21175            } else {
21176              // ── Filter to source-code files only ─────────────────────────
21177              // Binary, generated, and dependency files (node_modules, .git,
21178              // build artifacts) are skipped so they are never uploaded.
21179              var CODE_EXTS = new Set([
21180                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21181                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21182                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21183                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21184                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21185                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
21186                'tf','hcl','proto','thrift','avsc','graphql','gql'
21187              ]);
21188              var codeFiles = [];
21189              for (var i = 0; i < files.length; i++) {
21190                var f = files[i];
21191                var name = f.name;
21192                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
21193                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
21194                  codeFiles.push(f); continue;
21195                }
21196                var dot = name.lastIndexOf('.');
21197                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
21198              }
21199              // Collect specific .git metadata files for server-side git detection.
21200              // These have no source extension so they are excluded by the loop above,
21201              // but the server needs them to read branch/commit/author without running git.
21202              var gitMetaFiles = [];
21203              for (var i = 0; i < files.length; i++) {
21204                var f = files[i];
21205                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
21206                var gitIdx = rp.indexOf('/.git/');
21207                if (gitIdx < 0) continue;
21208                var gitRel = rp.slice(gitIdx + 1);
21209                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
21210                    gitRel === '.git/logs/HEAD' ||
21211                    gitRel.startsWith('.git/refs/heads/') ||
21212                    gitRel.startsWith('.git/refs/tags/')) {
21213                  gitMetaFiles.push(f);
21214                }
21215              }
21216              var uploadFiles = codeFiles.concat(gitMetaFiles);
21217              var total = files.length;
21218              var kept = codeFiles.length;
21219              if (kept === 0) {
21220                if (previewPanel && targetInput === pathInput)
21221                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
21222                if (browseBtn) browseBtn.disabled = false;
21223                inputEl.value = '';
21224                return;
21225              }
21226
21227              // ── Helper: apply upload result to UI ────────────────────────
21228              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
21229              function applyUploadResult(tmpPath, sizes) {
21230                targetInput.value = tmpPath;
21231                scrollInputToEnd(targetInput);
21232                if (sizes && SERVER_MODE) {
21233                  window._lastUploadSizes = sizes;
21234                  // Immediately show both sizes before preview loads.
21235                  var sizeText = document.getElementById('project-size-text');
21236                  var sizeBtn = document.getElementById('project-size-btn');
21237                  if (sizeText) {
21238                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21239                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21240                  }
21241                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21242                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21243                }
21244                if (targetInput === pathInput) {
21245                  updateReportTitleFromPath();
21246                  autoSetOutputDir(tmpPath);
21247                  fetchProjectHistory(tmpPath);
21248                  loadPreview();
21249                  suggestCoverageFile(tmpPath);
21250                }
21251                updateReview();
21252                if (browseBtn) browseBtn.disabled = false;
21253                inputEl.value = '';
21254              }
21255
21256              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
21257              if (typeof CompressionStream !== 'undefined') {
21258                if (previewPanel && targetInput === pathInput)
21259                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21260
21261                // Build a minimal POSIX ustar tar header for a single file entry.
21262                function buildUstarHeader(filePath, fileSize) {
21263                  var BLOCK = 512;
21264                  var hdr = new Uint8Array(BLOCK);
21265                  var enc = new TextEncoder();
21266                  function wStr(off, len, s) {
21267                    var b = enc.encode(s);
21268                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
21269                  }
21270                  function wOct(off, len, val) {
21271                    var s = val.toString(8);
21272                    while (s.length < len - 1) s = '0' + s;
21273                    wStr(off, len, s + '\0');
21274                  }
21275                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
21276                  var name = filePath, prefix = '';
21277                  if (filePath.length > 99) {
21278                    var split = filePath.lastIndexOf('/', 154);
21279                    if (split > 0 && filePath.length - split - 1 <= 99) {
21280                      prefix = filePath.substring(0, split);
21281                      name   = filePath.substring(split + 1);
21282                    } else { name = filePath.substring(0, 99); }
21283                  }
21284                  wStr(0,   100, name);          // name
21285                  wOct(100,   8, 0o000644);      // mode
21286                  wOct(108,   8, 0);             // uid
21287                  wOct(116,   8, 0);             // gid
21288                  wOct(124,  12, fileSize);      // size
21289                  wOct(136,  12, 0);             // mtime (epoch)
21290                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
21291                  hdr[156] = 48;                 // type flag '0' = regular file
21292                  wStr(157, 100, '');            // linkname
21293                  wStr(257,   6, 'ustar');       // magic
21294                  wStr(263,   2, '00');          // version
21295                  wStr(265,  32, '');            // uname
21296                  wStr(297,  32, '');            // gname
21297                  wOct(329,   8, 0);             // devmajor
21298                  wOct(337,   8, 0);             // devminor
21299                  wStr(345, 155, prefix);        // prefix
21300                  // Compute checksum (sum of all bytes, placeholder = 32).
21301                  var chk = 0;
21302                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21303                  var cs = chk.toString(8);
21304                  while (cs.length < 6) cs = '0' + cs;
21305                  wStr(148, 8, cs + '\0 ');
21306                  return hdr;
21307                }
21308
21309                // Build tar.gz one file at a time, piping through CompressionStream.
21310                // RAM usage = compressed output buffer + one file at a time.
21311                (async function () {
21312                  try {
21313                    var BLOCK = 512;
21314                    var cs     = new CompressionStream('gzip');
21315                    var writer = cs.writable.getWriter();
21316                    var chunks = [];
21317                    var reader = cs.readable.getReader();
21318                    var collecting = (async function () {
21319                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
21320                    })();
21321
21322                    for (var i = 0; i < uploadFiles.length; i++) {
21323                      var file = uploadFiles[i];
21324                      var path = file.webkitRelativePath || file.name;
21325                      var buf  = await file.arrayBuffer();
21326                      var data = new Uint8Array(buf);
21327                      // Header block
21328                      await writer.write(buildUstarHeader(path, data.length));
21329                      // Data padded to 512-byte boundary
21330                      if (data.length > 0) {
21331                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21332                        var block  = new Uint8Array(padded);
21333                        block.set(data);
21334                        await writer.write(block);
21335                      }
21336                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21337                        if (previewPanel && targetInput === pathInput)
21338                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21339                      }
21340                    }
21341                    // End-of-archive: two 512-byte zero blocks
21342                    await writer.write(new Uint8Array(BLOCK * 2));
21343                    await writer.close();
21344                    await collecting;
21345
21346                    var blob = new Blob(chunks, { type: 'application/gzip' });
21347                    var sizeMB = (blob.size / 1048576).toFixed(1);
21348                    if (previewPanel && targetInput === pathInput)
21349                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21350
21351                    var resp = await fetch('/api/upload-tarball', {
21352                      method: 'POST',
21353                      headers: { 'Content-Type': 'application/gzip' },
21354                      body: blob
21355                    });
21356                    var d = await resp.json();
21357                    if (d && d.tmp_path) {
21358                      applyUploadResult(d.tmp_path, {
21359                        compressed_bytes: d.compressed_bytes || 0,
21360                        original_bytes: d.original_bytes || 0
21361                      });
21362                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21363                  } catch (e) {
21364                    showBannerToast('Upload failed: ' + String(e), true);
21365                    if (browseBtn) browseBtn.disabled = false;
21366                    inputEl.value = '';
21367                  }
21368                })();
21369
21370              } else {
21371                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21372                // Used only on browsers that lack CompressionStream (pre-2023).
21373                var BATCH = 200;
21374                var batches = [];
21375                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21376                var totalBatches = batches.length;
21377                if (previewPanel && targetInput === pathInput)
21378                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21379
21380                function sendBatch(idx, currentUploadId, lastTmpPath) {
21381                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21382                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
21383                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21384                  Promise.all(batches[idx].map(function (file) {
21385                    return fileToBase64(file).then(function (b64) {
21386                      return { path: file.webkitRelativePath || file.name, content: b64 };
21387                    });
21388                  })).then(function (fileList) {
21389                    var body = { files: fileList };
21390                    if (currentUploadId) body.upload_id = currentUploadId;
21391                    return fetch('/api/upload-directory', {
21392                      method: 'POST', headers: { 'Content-Type': 'application/json' },
21393                      body: JSON.stringify(body)
21394                    }).then(function (r) { return r.json(); });
21395                  }).then(function (d) {
21396                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21397                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21398                  }).catch(function (e) {
21399                    showBannerToast('Upload failed: ' + String(e), true);
21400                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21401                  });
21402                }
21403                sendBatch(0, null, '');
21404              }
21405            }
21406          };
21407          inputEl.click();
21408          return;
21409        }
21410
21411        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21412        if (browseButton) browseButton.disabled = true;
21413
21414        if (previewPanel && targetInput === pathInput) {
21415          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21416        }
21417
21418        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
21419          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21420          .then(function (data) {
21421            if (data && data.selected_path) {
21422              targetInput.value = data.selected_path;
21423              scrollInputToEnd(targetInput);
21424
21425              if (targetInput === pathInput) {
21426                updateReportTitleFromPath();
21427                autoSetOutputDir(data.selected_path);
21428                fetchProjectHistory(data.selected_path);
21429                loadPreview();
21430                suggestCoverageFile(data.selected_path);
21431              }
21432
21433              updateReview();
21434            } else if (targetInput === pathInput) {
21435              loadPreview();
21436            }
21437          })
21438          .catch(function () {
21439            window.alert("Directory picker request failed.");
21440            if (previewPanel && targetInput === pathInput) {
21441              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21442            }
21443          })
21444          .finally(function () {
21445            if (browseButton) browseButton.disabled = false;
21446          });
21447      }
21448
21449      if (themeToggle) {
21450        themeToggle.addEventListener("click", function () {
21451          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21452          applyTheme(nextTheme);
21453          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21454        });
21455      }
21456
21457      stepButtons.forEach(function (button) {
21458        button.addEventListener("click", function () {
21459          var target = Number(button.getAttribute("data-step-target"));
21460          // Block jumping forward off step 1 while the preview / upload is running
21461          // or while a multi-repository selection is unacknowledged.
21462          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21463          setStep(target);
21464        });
21465      });
21466
21467      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21468        button.addEventListener("click", function () {
21469          var target = Number(button.getAttribute("data-step-target")) || 1;
21470          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21471          setStep(target);
21472        });
21473      });
21474
21475      // True when the project path is untouched from the bundled sample default.
21476      function isDefaultSamplePath() {
21477        return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21478      }
21479
21480      var defaultPathOverlay = document.getElementById("default-path-overlay");
21481      function closeDefaultPathModal() {
21482        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21483      }
21484      function openDefaultPathModal() {
21485        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21486      }
21487
21488      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21489        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21490        // that borrow the .next-step style class but carry no data-next target).
21491        if (!button.hasAttribute("data-next")) return;
21492        button.addEventListener("click", function () {
21493          // Guard step 1 → 2: block while the scope preview / upload is still running
21494          // or while a multi-repository selection is unacknowledged.
21495          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21496          // Guard step 1 → 2: warn when the project path is still the sample default.
21497          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21498            openDefaultPathModal();
21499            return;
21500          }
21501          updateReview();
21502          setStep(Number(button.getAttribute("data-next")));
21503        });
21504      });
21505
21506      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21507        if (!button.hasAttribute("data-prev")) return;
21508        button.addEventListener("click", function () {
21509          setStep(Number(button.getAttribute("data-prev")));
21510        });
21511      });
21512
21513      // Default-sample-path confirmation modal wiring.
21514      var defaultPathProceed = document.getElementById("default-path-proceed");
21515      if (defaultPathProceed) {
21516        defaultPathProceed.addEventListener("click", function () {
21517          closeDefaultPathModal();
21518          updateReview();
21519          setStep(2);
21520        });
21521      }
21522      var defaultPathCancel = document.getElementById("default-path-cancel");
21523      if (defaultPathCancel) {
21524        defaultPathCancel.addEventListener("click", function () {
21525          closeDefaultPathModal();
21526          if (pathInput) { pathInput.focus(); pathInput.select(); }
21527        });
21528      }
21529      if (defaultPathOverlay) {
21530        defaultPathOverlay.addEventListener("click", function (e) {
21531          if (e.target === defaultPathOverlay) closeDefaultPathModal();
21532        });
21533      }
21534      document.addEventListener("keydown", function (e) {
21535        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21536          closeDefaultPathModal();
21537        }
21538      });
21539
21540      document.addEventListener("keydown", function (e) {
21541        var tag = (document.activeElement || {}).tagName || "";
21542        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21543        if (e.altKey || e.ctrlKey || e.metaKey) return;
21544        if (e.key === "ArrowRight" && currentStep < 4) {
21545          if (currentStep === 1 && step1ForwardBlocked()) return;
21546          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21547          updateReview(); setStep(currentStep + 1);
21548        }
21549        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21550      });
21551
21552      if (useSamplePath) {
21553        useSamplePath.addEventListener("click", function () {
21554          pathInput.value = "testing/fixtures/basic";
21555          updateReportTitleFromPath();
21556          autoSetOutputDir("testing/fixtures/basic");
21557          loadPreview();
21558          suggestCoverageFile("testing/fixtures/basic");
21559        });
21560      }
21561
21562      if (useDefaultOutput) {
21563        useDefaultOutput.addEventListener("click", function () {
21564          delete outputDirInput.dataset.userEdited;
21565          autoSetOutputDir(pathInput ? pathInput.value : "");
21566          updateReview();
21567        });
21568      }
21569
21570      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21571      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21572
21573      // ── Drag-and-drop directory upload (server mode only) ─────────────────
21574      // Dropping a folder onto the path field bypasses Chrome's
21575      // "Upload X files to this site?" confirmation dialog.
21576      async function readDirRecursively(dirEntry, basePath) {
21577        var reader = dirEntry.createReader();
21578        var all = [];
21579        for (;;) {
21580          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21581          if (!batch.length) break;
21582          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21583        }
21584        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21585        var out = [];
21586        for (var i = 0; i < all.length; i++) {
21587          var sub = all[i];
21588          if (sub.isFile) {
21589            var f = await new Promise(function(res) { sub.file(res); });
21590            out.push({ file: f, path: basePath + '/' + sub.name });
21591          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21592            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21593            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21594          }
21595        }
21596        return out;
21597      }
21598
21599      function setupPathDropZone() {
21600        if (!SERVER_MODE || !pathInput) return;
21601        var CODE_EXTS = new Set([
21602          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21603          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21604          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21605          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21606          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21607          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21608        ]);
21609        pathInput.addEventListener('dragover', function(e) {
21610          e.preventDefault();
21611          pathInput.classList.add('drag-over');
21612        });
21613        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21614        pathInput.addEventListener('drop', function(e) {
21615          e.preventDefault();
21616          pathInput.classList.remove('drag-over');
21617          var items = e.dataTransfer.items;
21618          if (!items || !items.length) return;
21619          var dirEntry = null;
21620          for (var i = 0; i < items.length; i++) {
21621            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21622            if (entry && entry.isDirectory) { dirEntry = entry; break; }
21623          }
21624          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21625          var btn = browsePath;
21626          if (btn) btn.disabled = true;
21627          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21628
21629          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21630            var total = allEntries.length;
21631            var codeEntries = allEntries.filter(function(e) {
21632              var n = e.file.name;
21633              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21634              var dot = n.lastIndexOf('.');
21635              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21636            });
21637            var kept = codeEntries.length;
21638            if (kept === 0) {
21639              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21640              if (btn) btn.disabled = false; return;
21641            }
21642
21643            function finish(tmpPath, sizes) {
21644              pathInput.value = tmpPath;
21645              scrollInputToEnd(pathInput);
21646              if (sizes) {
21647                window._lastUploadSizes = sizes;
21648                var sizeText = document.getElementById('project-size-text');
21649                var sizeBtn = document.getElementById('project-size-btn');
21650                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21651                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21652                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21653                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21654              }
21655              updateReportTitleFromPath();
21656              autoSetOutputDir(tmpPath);
21657              fetchProjectHistory(tmpPath);
21658              loadPreview();
21659              suggestCoverageFile(tmpPath);
21660              updateReview();
21661              if (btn) btn.disabled = false;
21662            }
21663
21664            if (typeof CompressionStream === 'undefined') {
21665              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21666              if (btn) btn.disabled = false; return;
21667            }
21668
21669            try {
21670              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21671              var BLOCK = 512;
21672              var cs = new CompressionStream('gzip');
21673              var wtr = cs.writable.getWriter();
21674              var chunks = [];
21675              var rdr = cs.readable.getReader();
21676              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21677
21678              function buildHdr(fp, sz) {
21679                var hdr = new Uint8Array(BLOCK);
21680                var enc = new TextEncoder();
21681                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]; }
21682                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21683                var nm = fp, pfx = '';
21684                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); } }
21685                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21686                for (var i = 148; i < 156; i++) hdr[i] = 32;
21687                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);
21688                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21689                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21690                return hdr;
21691              }
21692
21693              for (var i = 0; i < codeEntries.length; i++) {
21694                var ce = codeEntries[i];
21695                var buf = await ce.file.arrayBuffer();
21696                var data = new Uint8Array(buf);
21697                await wtr.write(buildHdr(ce.path, data.length));
21698                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21699                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21700                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21701              }
21702              await wtr.write(new Uint8Array(BLOCK * 2));
21703              await wtr.close();
21704              await collecting;
21705
21706              var blob = new Blob(chunks, { type: 'application/gzip' });
21707              var sizeMB = (blob.size / 1048576).toFixed(1);
21708              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21709              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21710              var d = await resp.json();
21711              if (d && d.tmp_path) {
21712                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21713              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21714            } catch (err) {
21715              showBannerToast('Upload failed: ' + String(err), true);
21716              if (btn) btn.disabled = false;
21717            }
21718          }).catch(function(err) {
21719            showBannerToast('Could not read folder: ' + String(err), true);
21720            if (btn) btn.disabled = false;
21721          });
21722        });
21723      }
21724      setupPathDropZone();
21725      if (browseCoverage) {
21726        browseCoverage.addEventListener("click", function () {
21727          pickDirectory(coverageInput || pathInput, "coverage");
21728        });
21729      }
21730
21731      function setCovStatus(state, opts) {
21732        if (!covScanStatus) return;
21733        opts = opts || {};
21734        covScanStatus.className = "cov-scan-status cov-scan-" + state;
21735        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21736        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>';
21737        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>';
21738        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>';
21739        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>';
21740        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21741        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21742        if (state === "scanning") {
21743          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21744        } else if (state === "found") {
21745          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21746          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21747          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21748          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21749        } else if (state === "hint") {
21750          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21751          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
21752          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>';
21753        } else if (state === "none") {
21754          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21755          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
21756        }
21757        html += '</div></div>';
21758        covScanStatus.innerHTML = html;
21759        if (state === "found") {
21760          var useBtn = covScanStatus.querySelector(".cov-scan-use");
21761          if (useBtn) useBtn.addEventListener("click", function () {
21762            if (coverageInput) coverageInput.value = "";
21763            covAutoFilled = false;
21764            setCovStatus("idle");
21765          });
21766        }
21767      }
21768
21769      function suggestCoverageFile(projectPath) {
21770        if (!coverageInput || !covScanStatus) return;
21771        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21772        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21773        clearTimeout(coverageSuggestTimer);
21774        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21775        setCovStatus("scanning");
21776        coverageSuggestTimer = setTimeout(function () {
21777          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21778            .then(function (r) { return r.json(); })
21779            .then(function (d) {
21780              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21781              if (!d) { setCovStatus("none"); return; }
21782              if (d.found) {
21783                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21784                setCovStatus("found", { found: d.found, tool: d.tool });
21785              } else if (d.tool && d.hint) {
21786                setCovStatus("hint", { tool: d.tool, hint: d.hint });
21787              } else {
21788                setCovStatus("none");
21789              }
21790            })
21791            .catch(function () { setCovStatus("idle"); });
21792        }, 600);
21793      }
21794
21795      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21796
21797      if (coverageInput) coverageInput.addEventListener("input", function () {
21798        covAutoFilled = false;
21799        if (!this.value.trim()) setCovStatus("idle");
21800      });
21801
21802      // ── Language pill overflow: collapse to "+N more" chip ─────────────
21803      function collapseLanguagePills() {
21804        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21805        rows.forEach(function(row) {
21806          // Remove any previous overflow chip
21807          var prev = row.querySelector('.lang-overflow-chip');
21808          if (prev) prev.remove();
21809          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21810          pills.forEach(function(p) { p.style.display = ''; });
21811          if (!pills.length) return;
21812
21813          // Measure after restoring all pills
21814          var containerRight = row.getBoundingClientRect().right;
21815          var hidden = [];
21816          for (var i = pills.length - 1; i >= 1; i--) {
21817            var rect = pills[i].getBoundingClientRect();
21818            if (rect.right > containerRight + 2) {
21819              hidden.unshift(pills[i]);
21820              pills[i].style.display = 'none';
21821            } else {
21822              break;
21823            }
21824          }
21825
21826          if (hidden.length) {
21827            var chip = document.createElement('button');
21828            chip.type = 'button';
21829            chip.className = 'language-pill lang-overflow-chip';
21830            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21831            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21832            row.appendChild(chip);
21833          }
21834        });
21835      }
21836
21837      // Run after preview loads (preview panel populates language pills)
21838      var _origLoadPreviewCb = window.__previewLoaded;
21839      document.addEventListener('previewLoaded', collapseLanguagePills);
21840      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21841      setTimeout(collapseLanguagePills, 400);
21842
21843      // ── Project history & output dir auto-set ──────────────────────────
21844      var wsOutputRoot   = document.getElementById("ws-output-root");
21845      var wsScanCount    = document.getElementById("ws-scan-count");
21846      var wsLastScan     = document.getElementById("ws-last-scan");
21847      var historyBadge   = document.getElementById("path-history-badge");
21848      var historyTimer   = null;
21849
21850      var wsOutputLink = document.getElementById("ws-output-link");
21851      function syncStripOutputRoot() {
21852        var val = outputDirInput ? outputDirInput.value : "";
21853        var display = val || "project/sloc";
21854        if (wsOutputRoot) wsOutputRoot.textContent = display;
21855        if (wsOutputLink) wsOutputLink.dataset.folder = val;
21856      }
21857
21858      function scrollInputToEnd(input) {
21859        if (!input) return;
21860        // Defer so the DOM has the new value before we measure scroll width.
21861        requestAnimationFrame(function () {
21862          input.scrollLeft = input.scrollWidth;
21863          input.selectionStart = input.selectionEnd = input.value.length;
21864        });
21865      }
21866
21867      function autoSetOutputDir(projectPath) {
21868        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21869        if (GIT_MODE && GIT_OUTPUT_DIR) {
21870          outputDirInput.value = GIT_OUTPUT_DIR;
21871          scrollInputToEnd(outputDirInput);
21872          syncStripOutputRoot();
21873          updateReview();
21874          return;
21875        }
21876        if (!projectPath || !projectPath.trim()) return;
21877        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21878        outputDirInput.value = cleaned + "/sloc";
21879        scrollInputToEnd(outputDirInput);
21880        syncStripOutputRoot();
21881        updateReview();
21882      }
21883
21884      var wsBranch = document.getElementById("ws-branch");
21885
21886      function fetchProjectHistory(projectPath) {
21887        if (!projectPath || !projectPath.trim()) {
21888          if (wsScanCount) wsScanCount.textContent = "\u2014";
21889          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
21890          if (wsBranch)    wsBranch.textContent    = "\u2014";
21891          if (historyBadge) historyBadge.style.display = "none";
21892          return;
21893        }
21894        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21895          .then(function (r) { return r.ok ? r.json() : null; })
21896          .then(function (data) {
21897            if (!data) return;
21898            var countStr = data.scan_count > 0
21899              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21900              : "never";
21901            var tsStr = data.last_scan_timestamp
21902              ? data.last_scan_timestamp.replace(" UTC","")
21903              : "\u2014";
21904            if (wsScanCount) wsScanCount.textContent = countStr;
21905            if (wsLastScan)  wsLastScan.textContent  = tsStr;
21906            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
21907            if (data.scan_count > 0) {
21908              if (historyBadge) {
21909                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21910                historyBadge.textContent = data.scan_count + " previous scan" +
21911                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21912                  "Last: " + (data.last_scan_timestamp || "\u2014") +
21913                  " \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.";
21914                historyBadge.className = "path-history-badge found";
21915                historyBadge.style.display = "";
21916              }
21917            } else {
21918              if (historyBadge) historyBadge.style.display = "none";
21919            }
21920          })
21921          .catch(function () {});
21922      }
21923
21924      function onPathChange() {
21925        var val = pathInput ? pathInput.value : "";
21926        // Discard stale upload sizes when the user edits the path manually.
21927        window._lastUploadSizes = null;
21928        updateReportTitleFromPath();
21929        autoSetOutputDir(val);
21930        updateSidebarSummary();
21931        clearTimeout(historyTimer);
21932        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21933        if (previewTimer) clearTimeout(previewTimer);
21934        previewTimer = setTimeout(loadPreview, 280);
21935        suggestCoverageFile(val);
21936      }
21937
21938      if (pathInput) {
21939        pathInput.addEventListener("input", onPathChange);
21940      }
21941
21942      if (outputDirInput) {
21943        outputDirInput.addEventListener("input", function () {
21944          outputDirInput.dataset.userEdited = "1";
21945          syncStripOutputRoot();
21946          updateReview();
21947        });
21948      }
21949
21950      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21951        if (!node) return;
21952        node.addEventListener("input", function () {
21953          updateReview();
21954          if (previewTimer) clearTimeout(previewTimer);
21955          previewTimer = setTimeout(loadPreview, 280);
21956        });
21957      });
21958
21959      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21960        var node = document.getElementById(id);
21961        if (node) node.addEventListener("change", updateReview);
21962      });
21963
21964      if (reportTitleInput) {
21965        reportTitleInput.addEventListener("input", function () {
21966          reportTitleTouched = reportTitleInput.value.trim().length > 0;
21967          updateReportTitleFromPath();
21968          updateReview();
21969        });
21970      }
21971
21972      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21973      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21974      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21975      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21976
21977      if (coverageInput) {
21978        coverageInput.addEventListener("input", function () {
21979          if (coverageInput.value.trim()) setCovStatus("idle");
21980        });
21981      }
21982
21983      if (form && loading && submitButton) {
21984        form.addEventListener("submit", function (e) {
21985          e.preventDefault();
21986          submitButton.disabled = true;
21987          submitButton.textContent = "Scanning...";
21988          startAsyncAnalysis(new FormData(form));
21989        });
21990      }
21991
21992      function openPath(folder) {
21993        if (!folder) return;
21994        fetch('/open-path?path=' + encodeURIComponent(folder))
21995          .then(function (r) { return r.json(); })
21996          .then(function (d) {
21997            if (d && d.server_mode_disabled)
21998              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21999          })
22000          .catch(function () {});
22001      }
22002
22003      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
22004        btn.addEventListener('click', function () {
22005          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
22006        });
22007      });
22008
22009      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
22010      if (wsOutputLink) {
22011        wsOutputLink.addEventListener('click', function () {
22012          openPath(wsOutputLink.dataset.folder || '');
22013        });
22014      }
22015
22016      loadSavedTheme();
22017      updateMixedPolicyUI();
22018      updatePythonDocstringUI();
22019      applyScanPreset();
22020      updatePresetDescriptions();
22021      applyArtifactPreset();
22022      updateReview();
22023      updateScrollProgress(); // initialise bar to 0% (step 1)
22024      window.addEventListener("scroll", updateScrollProgress, { passive: true });
22025      onPathChange();         // seed output dir, history badge, and preview from initial path
22026      updateStepNav(1);
22027
22028      // Restore step from URL hash on initial load (e.g., back-forward cache)
22029      (function() {
22030        var hashMatch = location.hash.match(/^#step([1-4])$/);
22031        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
22032      })();
22033
22034      (function randomizeWatermarks() {
22035        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
22036        if (!wms.length) return;
22037        var placed = [];
22038        function tooClose(top, left) {
22039          for (var i = 0; i < placed.length; i++) {
22040            var dt = Math.abs(placed[i][0] - top);
22041            var dl = Math.abs(placed[i][1] - left);
22042            if (dt < 16 && dl < 12) return true;
22043          }
22044          return false;
22045        }
22046        function pick(leftBand) {
22047          for (var attempt = 0; attempt < 50; attempt++) {
22048            var top = Math.random() * 88 + 2;
22049            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22050            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22051          }
22052          var top = Math.random() * 88 + 2;
22053          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22054          placed.push([top, left]);
22055          return [top, left];
22056        }
22057        var half = Math.floor(wms.length / 2);
22058        wms.forEach(function (img, i) {
22059          var pos = pick(i < half);
22060          var size = Math.floor(Math.random() * 80 + 110);
22061          var rot = (Math.random() * 360).toFixed(1);
22062          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
22063          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;
22064        });
22065      })();
22066
22067      (function spawnCodeParticles() {
22068        var container = document.getElementById('code-particles');
22069        if (!container) return;
22070        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'];
22071        for (var i = 0; i < 38; i++) {
22072          (function(idx) {
22073            var el = document.createElement('span');
22074            el.className = 'code-particle';
22075            el.textContent = snippets[idx % snippets.length];
22076            var left = Math.random() * 94 + 2;
22077            var top = Math.random() * 88 + 6;
22078            var dur = (Math.random() * 10 + 9).toFixed(1);
22079            var delay = (Math.random() * 18).toFixed(1);
22080            var rot = (Math.random() * 26 - 13).toFixed(1);
22081            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22082            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';
22083            container.appendChild(el);
22084          })(i);
22085        }
22086      })();
22087    })();
22088  </script>
22089  <script nonce="{{ csp_nonce }}">
22090    (function () {
22091      var raw = {{ prefill_json|safe }};
22092      if (!raw || typeof raw !== 'object' || !raw.path) return;
22093      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
22094      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
22095      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
22096      setVal('path', raw.path || '');
22097      setVal('include_globs', raw.include_globs || '');
22098      setVal('exclude_globs', raw.exclude_globs || '');
22099      setVal('output_dir', raw.output_dir || '');
22100      setVal('report_title', raw.report_title || '');
22101      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
22102      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
22103      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
22104      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
22105      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
22106      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
22107      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
22108      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
22109      setChecked('generate_html', raw.generate_html !== false);
22110      setChecked('generate_pdf', !!raw.generate_pdf);
22111      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
22112      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
22113      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
22114      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
22115      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
22116      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
22117      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
22118      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
22119      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
22120      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
22121      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
22122      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
22123      // Trigger dynamic UI updates after pre-fill.
22124      setTimeout(function () {
22125        var pathEl = document.getElementById('path');
22126        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
22127        var policyEl = document.getElementById('mixed_line_policy');
22128        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
22129      }, 80);
22130    })();
22131  </script>
22132  <script nonce="{{ csp_nonce }}">
22133  (function(){
22134    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'}];
22135    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);});}
22136    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22137    function init(){
22138      var btn=document.getElementById('settings-btn');if(!btn)return;
22139      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22140      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>';
22141      document.body.appendChild(m);
22142      var g=document.getElementById('scheme-grid');
22143      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);});
22144      var cl=document.getElementById('settings-close');
22145      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);});})();
22146      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');});
22147      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22148      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22149    }
22150    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22151  }());
22152  </script>
22153  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
22154    <div class="wb-ftip-arrow"></div>
22155    <span id="wb-ftip-text"></span>
22156  </div>
22157  <script nonce="{{ csp_nonce }}">(function(){
22158    var tip=document.getElementById('wb-ftip');
22159    var txt=document.getElementById('wb-ftip-text');
22160    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
22161    if(!tip||!txt)return;
22162    function pos(el){
22163      var r=el.getBoundingClientRect();
22164      tip.style.display='block';
22165      var tw=tip.offsetWidth;
22166      var lx=r.left+r.width/2-tw/2;
22167      if(lx<8)lx=8;
22168      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
22169      tip.style.left=lx+'px';
22170      tip.style.top=(r.bottom+8)+'px';
22171      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';}
22172    }
22173    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
22174      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
22175      el.addEventListener('mouseleave',function(){tip.style.display='none';});
22176    });
22177    window.addEventListener('blur',function(){tip.style.display='none';});
22178    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
22179  })();
22180  (function(){
22181    function fixArtifactHintSpacing(){
22182      var grid=document.querySelector('.artifact-grid');
22183      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
22184    }
22185    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
22186  }());
22187  (function(){
22188    var dot=document.getElementById('status-dot');
22189    var pingEl=document.getElementById('server-ping-ms');
22190    var tipEl=document.getElementById('server-tip-ping');
22191    var fm=document.getElementById('footer-mode');
22192    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)';}}
22193    function doPing(){
22194      var t0=performance.now();
22195      fetch('/healthz',{cache:'no-store'})
22196        .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);})
22197        .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)';}});
22198    }
22199    doPing();
22200    setInterval(doPing,5000);
22201    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');}
22202  })();
22203  </script>
22204  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
22205  <footer class="site-footer">
22206    local code analysis - metrics, history and reports
22207    &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>
22208    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22209    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22210    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22211    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22212  </footer>
22213</body>
22214</html>
22215"##,
22216    ext = "html"
22217)]
22218struct IndexTemplate {
22219    version: &'static str,
22220    prefill_json: String,
22221    csp_nonce: String,
22222    git_repo: String,
22223    git_ref: String,
22224    git_label_json: String,
22225    git_output_dir_json: String,
22226    server_mode: bool,
22227}
22228
22229// ── SplashTemplate ────────────────────────────────────────────────────────────
22230
22231#[derive(Template)]
22232#[template(
22233    source = r##"
22234<!doctype html>
22235<html lang="en">
22236<head>
22237  <meta charset="utf-8">
22238  <meta name="viewport" content="width=device-width, initial-scale=1">
22239  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
22240  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22241  <script type="application/ld+json">
22242  {
22243    "@context": "https://schema.org",
22244    "@type": "SoftwareApplication",
22245    "name": "oxide-sloc",
22246    "applicationCategory": "DeveloperApplication",
22247    "operatingSystem": "Windows, Linux",
22248    "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.",
22249    "softwareVersion": "{{ version }}",
22250    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
22251    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
22252    "url": "https://github.com/oxide-sloc/oxide-sloc",
22253    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
22254    "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",
22255    "programmingLanguage": "Rust",
22256    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
22257  }
22258  </script>
22259  <style nonce="{{ csp_nonce }}">
22260    :root {
22261      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
22262      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22263      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22264      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22265      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22266    }
22267    body.dark-theme {
22268      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22269      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22270    }
22271    *{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;}
22272    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22273    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22274    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22275    .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;}
22276    @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));}}
22277    .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);}
22278    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22279    .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));}
22280    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
22281    .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;}
22282    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22283    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22284    @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; } }
22285    .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;}
22286    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22287    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22288    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22289    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22290    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22291    .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;}
22292    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22293    .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);}
22294    .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;}
22295    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22296    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22297    .settings-modal-body{padding:14px 16px 16px;}
22298    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22299    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22300    .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;}
22301    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22302    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22303    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22304    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22305    .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;}
22306    .tz-select:focus{border-color:var(--oxide);}
22307    .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;}
22308    .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;}
22309    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
22310    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
22311    .hero{text-align:center;margin:0 auto 18px;}
22312    .hero-logo-wrap{display:inline-block;cursor:default;}
22313    .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;}
22314    .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;}
22315    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
22316    .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;}
22317    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%);}
22318    .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;
22319      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
22320      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
22321      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;}
22322    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
22323    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
22324    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;}
22325    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
22326    .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;}
22327    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22328    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22329    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22330    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22331    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22332    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22333    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22334    .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;}
22335    .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;}
22336    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22337    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22338    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22339    .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);}
22340    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22341    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22342    .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);}
22343    .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);}
22344    .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);}
22345    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22346    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22347    .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;}
22348    body.dark-theme .action-card-cta{color:var(--oxide);}
22349    .action-card.view .action-card-cta{color:var(--accent-2);}
22350    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22351    .action-card.compare .action-card-cta{color:#7c3aed;}
22352    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22353    .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);}
22354    .action-card.git-tools .action-card-cta{color:#15803d;}
22355    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22356    .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);}
22357    .action-card.trend .action-card-cta{color:#0e7490;}
22358    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22359    .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);}
22360    .action-card.automation .action-card-cta{color:#b45309;}
22361    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22362    .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);}
22363    .action-card.test-metrics .action-card-cta{color:#be185d;}
22364    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22365    .action-card:hover .action-card-cta{gap:12px;}
22366    .action-card.card-split{flex-direction:row;align-items:stretch;}
22367    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22368    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22369    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22370    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22371    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22372    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22373    .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;}
22374    .ac-badge.active{opacity:1;}
22375    .ac-badge.github{border-color:#555;color:#555;}
22376    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22377    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22378    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22379    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22380    body.dark-theme .ac-right-row{color:var(--muted);}
22381    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22382    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22383    .divider{height:1px;background:var(--line);margin:32px 0;}
22384    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22385    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22386    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22387    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22388      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22389    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22390    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22391    body.dark-theme .info-chip-val{color:var(--oxide);}
22392    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22393    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22394      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22395      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22396    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22397      border:6px solid transparent;border-top-color:var(--text);}
22398    .info-chip:hover .info-chip-tip{display:block;}
22399    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22400    .chip-slide.fading{filter:blur(5px);opacity:0;}
22401    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22402    .site-footer a{color:var(--muted);}
22403    .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;}
22404    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22405    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22406    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22407    .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;}
22408    .lan-badge.local{background:var(--oxide-2);}
22409    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22410    .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);}
22411    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22412    .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;}
22413    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22414    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22415    .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;}
22416    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22417    .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;}
22418    .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);}
22419    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22420    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22421    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22422    .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;}
22423    @media (max-height: 1100px) {
22424      .page{padding-top:10px;}
22425      .hero{margin-bottom:10px;}
22426      .hero-logo{width:54px;height:60px;}
22427      .hero-logo-shadow{width:42px;}
22428      .hero-title{font-size:28px;}
22429      .hero-subtitle{font-size:13px;}
22430      .card-sections{gap:12px;margin-bottom:6px;}
22431      .card-section-grid-2,.card-section-grid-3{gap:10px;}
22432      .action-card{padding:8px 15px 8px;}
22433      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22434      .action-card-icon svg{width:18px;height:18px;}
22435      .action-card-title{font-size:13px;}
22436      .action-card-desc{font-size:11px;margin-bottom:6px;}
22437      .action-card-cta{font-size:11px;}
22438      .ac-right-row{font-size:11px;}
22439      .divider{margin:14px 0;}
22440      .info-strip{gap:7px;margin-bottom:8px;}
22441      .info-chip{padding:7px 10px;}
22442      .info-chip-val{font-size:13px;}
22443      .info-chip-label{font-size:9px;}
22444      .site-footer{padding:8px 24px;font-size:12px;}
22445      .lan-local-hint{margin-top:8px;}
22446    }
22447    @media (max-height: 850px) {
22448      .page{padding-top:6px;}
22449      .hero{margin-bottom:6px;}
22450      .hero-logo{width:42px;height:46px;}
22451      .hero-title{font-size:22px;}
22452      .hero-subtitle{font-size:12px;}
22453      .card-sections{gap:10px;}
22454      .action-card-desc{margin-bottom:4px;}
22455      .divider{margin:8px 0;}
22456      .info-strip{margin-bottom:6px;}
22457      .lan-local-hint{margin-top:10px;}
22458    }
22459  </style>
22460</head>
22461<body>
22462  <div class="background-watermarks" aria-hidden="true">
22463    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22464    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22465    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22466    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22467    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22468    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22469    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22470  </div>
22471  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22472  <div class="top-nav">
22473    <div class="top-nav-inner">
22474      <a class="brand" href="/">
22475        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22476        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22477      </a>
22478      <div class="nav-right">
22479        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22480        <div class="nav-dropdown">
22481          <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>
22482          <div class="nav-dropdown-menu">
22483            <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>
22484          </div>
22485        </div>
22486        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22487        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22488        <div class="nav-dropdown">
22489          <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>
22490          <div class="nav-dropdown-menu">
22491            <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>
22492          </div>
22493        </div>
22494        <div class="server-status-wrap" id="server-status-wrap">
22495          <div class="nav-pill server-online-pill" id="server-status-pill">
22496            <span class="status-dot" id="status-dot"></span>
22497            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22498            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22499          </div>
22500          <div class="server-status-tip">
22501            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22502            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22503          </div>
22504        </div>
22505        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22506          <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>
22507        </button>
22508        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22509          <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>
22510          <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>
22511        </button>
22512      </div>
22513    </div>
22514  </div>
22515
22516  <div class="page">
22517    <div class="hero">
22518      <div class="hero-logo-wrap" id="hero-logo-wrap">
22519        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22520      </div>
22521      <div class="hero-logo-shadow"></div>
22522      <div class="hero-title-wrap">
22523        <div class="hero-title-aura" aria-hidden="true"></div>
22524        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22525      </div>
22526      <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>
22527    </div>
22528
22529    <div class="card-sections">
22530
22531      <div>
22532        <div class="card-section-label">Analysis</div>
22533        <div class="card-section-grid-2">
22534          <a class="action-card scan card-split" href="/scan-setup">
22535            <div class="action-card-left">
22536              <div class="action-card-icon">
22537                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22538              </div>
22539              <div class="action-card-title">Scan Project</div>
22540              <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>
22541              <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>
22542            </div>
22543            <div class="action-card-sep"></div>
22544            <div class="action-card-right">
22545              <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>
22546              <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>
22547              <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>
22548              <div class="ac-right-stat" id="acp-scan-stat"></div>
22549            </div>
22550          </a>
22551          <a class="action-card test-metrics card-split" href="/test-metrics">
22552            <div class="action-card-left">
22553              <div class="action-card-icon">
22554                <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>
22555              </div>
22556              <div class="action-card-title">Test Metrics</div>
22557              <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>
22558              <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>
22559            </div>
22560            <div class="action-card-sep"></div>
22561            <div class="action-card-right">
22562              <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>
22563              <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>
22564              <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>
22565              <div class="ac-right-stat" id="acp-test-stat"></div>
22566            </div>
22567          </a>
22568        </div>
22569      </div>
22570
22571      <div>
22572        <div class="card-section-label">Reports &amp; Insights</div>
22573        <div class="card-section-grid-3">
22574          <a class="action-card view" href="/view-reports">
22575            <div class="action-card-icon">
22576              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22577            </div>
22578            <div class="action-card-title">View Reports</div>
22579            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22580            <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>
22581          </a>
22582          <a class="action-card compare" href="/compare-scans">
22583            <div class="action-card-icon">
22584              <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>
22585            </div>
22586            <div class="action-card-title">Compare Scans</div>
22587            <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>
22588            <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>
22589          </a>
22590          <a class="action-card trend" href="/trend-reports">
22591            <div class="action-card-icon">
22592              <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>
22593            </div>
22594            <div class="action-card-title">Trend Report</div>
22595            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22596            <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>
22597          </a>
22598        </div>
22599      </div>
22600
22601      <div>
22602        <div class="card-section-label">Developer Tools</div>
22603        <div class="card-section-grid-2">
22604          <a class="action-card git-tools card-split" href="/git-browser">
22605            <div class="action-card-left">
22606              <div class="action-card-icon">
22607                <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>
22608              </div>
22609              <div class="action-card-title">Git Browser</div>
22610              <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>
22611              <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>
22612            </div>
22613            <div class="action-card-sep"></div>
22614            <div class="action-card-right">
22615              <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>
22616              <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>
22617              <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>
22618            </div>
22619          </a>
22620          <a class="action-card automation card-split" href="/integrations">
22621            <div class="action-card-left">
22622              <div class="action-card-icon">
22623                <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>
22624              </div>
22625              <div class="action-card-title">Integrations</div>
22626              <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>
22627              <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>
22628            </div>
22629            <div class="action-card-sep"></div>
22630            <div class="action-card-right">
22631              <div class="ac-badges-grid">
22632                <span class="ac-badge github"     id="acp-gh">GitHub</span>
22633                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
22634                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
22635                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22636              </div>
22637              <div class="ac-right-stat" id="acp-int-stat"></div>
22638            </div>
22639          </a>
22640        </div>
22641      </div>
22642
22643    </div>
22644
22645    {% if server_mode %}
22646    <div class="lan-card server">
22647      <div class="lan-card-header">
22648        <span class="lan-badge">LAN server</span>
22649        Accessible on your network
22650      </div>
22651      {% if let Some(ip) = lan_ip %}
22652      <div class="lan-url-row">
22653        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22654        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22655          <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>
22656          Copy URL
22657        </button>
22658      </div>
22659      <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>
22660      {% if has_api_key %}
22661      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
22662      {% endif %}
22663      {% else %}
22664      <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>
22665      {% endif %}
22666    </div>
22667    {% endif %}
22668
22669    <div class="divider"></div>
22670
22671    <div class="info-strip">
22672      <div class="info-chip">
22673        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22674        <div class="chip-slide">
22675          <div class="info-chip-val">60</div>
22676          <div class="info-chip-label">Languages</div>
22677        </div>
22678      </div>
22679      <div class="info-chip">
22680        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22681        <div class="chip-slide">
22682          <div class="info-chip-val">100%</div>
22683          <div class="info-chip-label">Self-contained</div>
22684        </div>
22685      </div>
22686      <div class="info-chip">
22687        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22688        <div class="chip-slide">
22689          <div class="info-chip-val">HTML+PDF</div>
22690          <div class="info-chip-label">Exportable reports</div>
22691        </div>
22692      </div>
22693      <div class="info-chip">
22694        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22695        <div class="chip-slide">
22696          <div class="info-chip-val">Webhook</div>
22697          <div class="info-chip-label">3 platforms</div>
22698        </div>
22699      </div>
22700      <div class="info-chip">
22701        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22702        <div class="chip-slide">
22703          <div class="info-chip-val">IEEE</div>
22704          <div class="info-chip-label">1045-1992</div>
22705        </div>
22706      </div>
22707    </div>
22708
22709    {% if lan_ip.is_none() %}
22710    <div class="lan-local-hint">
22711      <strong>Want teammates on the same network to access this?</strong><br>
22712      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
22713    </div>
22714    {% endif %}
22715  </div>
22716
22717  <footer class="site-footer">
22718    local code analysis - metrics, history and reports
22719    &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>
22720    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22721    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22722    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22723    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22724  </footer>
22725
22726  <script nonce="{{ csp_nonce }}">
22727    (function () {
22728      var storageKey = 'oxide-sloc-theme';
22729      var body = document.body;
22730      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22731      var toggle = document.getElementById('theme-toggle');
22732      if (toggle) toggle.addEventListener('click', function () {
22733        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22734        body.classList.toggle('dark-theme', next === 'dark');
22735        try { localStorage.setItem(storageKey, next); } catch(e) {}
22736      });
22737      var copyBtn = document.getElementById('lan-copy-btn');
22738      if (copyBtn) copyBtn.addEventListener('click', function() {
22739        var btn = this;
22740        var el = document.getElementById('lan-url-val');
22741        if (!el) return;
22742        var url = el.textContent.trim();
22743        if (navigator.clipboard) {
22744          navigator.clipboard.writeText(url).then(function() {
22745            var orig = btn.innerHTML;
22746            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!';
22747            setTimeout(function() { btn.innerHTML = orig; }, 1800);
22748          });
22749        }
22750      });
22751      (function randomizeWatermarks() {
22752        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22753        if (!wms.length) return;
22754        var placed = [];
22755        function tooClose(top, left) {
22756          for (var i = 0; i < placed.length; i++) {
22757            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22758            if (dt < 16 && dl < 12) return true;
22759          }
22760          return false;
22761        }
22762        function pick(leftBand) {
22763          for (var attempt = 0; attempt < 50; attempt++) {
22764            var top = Math.random() * 88 + 2;
22765            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22766            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22767          }
22768          var top = Math.random() * 88 + 2;
22769          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22770          placed.push([top, left]); return [top, left];
22771        }
22772        var half = Math.floor(wms.length / 2);
22773        wms.forEach(function (img, i) {
22774          var pos = pick(i < half);
22775          var size = Math.floor(Math.random() * 100 + 120);
22776          var rot = (Math.random() * 360).toFixed(1);
22777          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22778          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;
22779        });
22780      })();
22781
22782      (function spawnCodeParticles() {
22783        var container = document.getElementById('code-particles');
22784        if (!container) return;
22785        var snippets = [
22786          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22787          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22788          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22789          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22790          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22791        ];
22792        var count = 38;
22793        for (var i = 0; i < count; i++) {
22794          (function(idx) {
22795            var el = document.createElement('span');
22796            el.className = 'code-particle';
22797            var text = snippets[idx % snippets.length];
22798            el.textContent = text;
22799            var left = Math.random() * 94 + 2;
22800            var top = Math.random() * 88 + 6;
22801            var dur = (Math.random() * 10 + 9).toFixed(1);
22802            var delay = (Math.random() * 18).toFixed(1);
22803            var rot = (Math.random() * 26 - 13).toFixed(1);
22804            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22805            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22806              + '--rot:' + rot + 'deg;--op:' + op + ';'
22807              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22808            container.appendChild(el);
22809          })(i);
22810        }
22811      })();
22812      (function heroAnimations() {
22813        var sub = document.getElementById('hero-subtitle');
22814        if (sub) {
22815          var full = sub.textContent.trim();
22816          sub.textContent = '';
22817          sub.style.opacity = '1';
22818          var cursor = document.createElement('span');
22819          cursor.className = 'hero-cursor';
22820          sub.appendChild(cursor);
22821          var i = 0;
22822          setTimeout(function() {
22823            var iv = setInterval(function() {
22824              if (i < full.length) {
22825                sub.insertBefore(document.createTextNode(full[i]), cursor);
22826                i++;
22827              } else {
22828                clearInterval(iv);
22829                setTimeout(function() {
22830                  cursor.style.transition = 'opacity 1s ease';
22831                  cursor.style.opacity = '0';
22832                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22833                }, 2400);
22834              }
22835            }, 11);
22836          }, 374);
22837        }
22838      })();
22839      (function logoBob() {
22840        var logo = document.querySelector('.hero-logo');
22841        var shadow = document.querySelector('.hero-logo-shadow');
22842        if (!logo) return;
22843        var cycleStart = null, cycleDur = 3600;
22844        var peakY = -14, peakScale = 1.07, peakRot = 0;
22845        function newCycle() {
22846          cycleDur = 3000 + Math.random() * 1840;
22847          peakY = -(9 + Math.random() * 13.8);
22848          peakScale = 1.04 + Math.random() * 0.081;
22849          peakRot = (Math.random() * 11.5 - 5.75);
22850        }
22851        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22852        newCycle();
22853        function frame(ts) {
22854          if (cycleStart === null) cycleStart = ts;
22855          var t = (ts - cycleStart) / cycleDur;
22856          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22857          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22858          var y = peakY * phase;
22859          var sc = 1 + (peakScale - 1) * phase;
22860          var rot = peakRot * Math.sin(Math.PI * phase);
22861          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22862          if (shadow) {
22863            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22864            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22865          }
22866          requestAnimationFrame(frame);
22867        }
22868        requestAnimationFrame(frame);
22869      })();
22870      (function mouseEffects() {
22871        var heroTitle = document.getElementById('hero-title');
22872        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22873        function tick() {
22874          raf = null;
22875          if (heroTitle) {
22876            var r = heroTitle.getBoundingClientRect();
22877            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22878            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22879            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22880          }
22881        }
22882        document.addEventListener('mousemove', function(e) {
22883          mx = e.clientX; my = e.clientY;
22884          if (!raf) raf = requestAnimationFrame(tick);
22885        });
22886        document.addEventListener('mouseleave', function() {
22887          if (heroTitle) {
22888            heroTitle.style.transition = 'transform 0.5s ease';
22889            heroTitle.style.transform = '';
22890            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22891          }
22892        });
22893        document.querySelectorAll('.action-card').forEach(function(card) {
22894          card.addEventListener('mousemove', function(e) {
22895            var rect = card.getBoundingClientRect();
22896            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22897            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22898            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22899            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22900          });
22901          card.addEventListener('mouseleave', function() {
22902            card.style.transition = '';
22903            card.style.transform = '';
22904          });
22905        });
22906      })();
22907      (function chipSlideshow() {
22908        var slides = [
22909          [{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'}],
22910          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22911          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22912          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22913          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22914        ];
22915        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22916        var indices = [0,0,0,0,0];
22917        var paused = [false,false,false,false,false];
22918        chips.forEach(function(chip, i) {
22919          chip.addEventListener('mouseenter', function() { paused[i] = true; });
22920          chip.addEventListener('mouseleave', function() { paused[i] = false; });
22921        });
22922        function advance(i) {
22923          if (paused[i]) return;
22924          var chip = chips[i];
22925          var inner = chip.querySelector('.chip-slide');
22926          if (!inner) return;
22927          inner.classList.add('fading');
22928          setTimeout(function() {
22929            indices[i] = (indices[i] + 1) % slides[i].length;
22930            var s = slides[i][indices[i]];
22931            chip.querySelector('.info-chip-val').textContent = s.v;
22932            chip.querySelector('.info-chip-label').textContent = s.l;
22933            inner.classList.remove('fading');
22934          }, 720);
22935        }
22936        setInterval(function() {
22937          chips.forEach(function(chip, i) { advance(i); });
22938        }, 6000);
22939      })();
22940      (function cardLiveData() {
22941        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22942          var el = document.getElementById('acp-scan-stat');
22943          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22944        }).catch(function(){});
22945        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22946          var el = document.getElementById('acp-test-stat');
22947          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22948        }).catch(function(){});
22949        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22950          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22951          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22952          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22953          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22954          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22955          var stat = document.getElementById('acp-int-stat');
22956          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22957        }).catch(function(){});
22958        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22959          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22960        }).catch(function(){});
22961      })();
22962    })();
22963  </script>
22964  <script nonce="{{ csp_nonce }}">
22965  (function(){
22966    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'}];
22967    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);});}
22968    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22969    function init(){
22970      var btn=document.getElementById('settings-btn');if(!btn)return;
22971      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22972      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>';
22973      document.body.appendChild(m);
22974      var g=document.getElementById('scheme-grid');
22975      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);});
22976      var cl=document.getElementById('settings-close');
22977      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);});})();
22978      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');});
22979      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22980      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22981    }
22982    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22983  }());
22984  </script>
22985  <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>
22986</body>
22987</html>
22988"##,
22989    ext = "html"
22990)]
22991struct SplashTemplate {
22992    csp_nonce: String,
22993    server_mode: bool,
22994    lan_ip: Option<String>,
22995    port: u16,
22996    version: &'static str,
22997    has_api_key: bool,
22998}
22999
23000// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
23001
23002#[derive(Template)]
23003#[template(
23004    source = r##"
23005<!doctype html>
23006<html lang="en">
23007<head>
23008  <meta charset="utf-8">
23009  <meta name="viewport" content="width=device-width, initial-scale=1">
23010  <title>OxideSLOC — Start a Scan</title>
23011  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23012  <style nonce="{{ csp_nonce }}">
23013    :root {
23014      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
23015      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
23016      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
23017      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
23018      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
23019    }
23020    body.dark-theme {
23021      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
23022      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
23023    }
23024    *{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;}
23025    .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);}
23026    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
23027    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
23028    .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));}
23029    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
23030    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
23031    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
23032    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
23033    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23034    @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; } }
23035    .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;}
23036    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
23037    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
23038    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
23039    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
23040    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
23041    .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;}
23042    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23043    .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);}
23044    .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;}
23045    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23046    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23047    .settings-modal-body{padding:14px 16px 16px;}
23048    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23049    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23050    .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;}
23051    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23052    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23053    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23054    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23055    .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;}
23056    .tz-select:focus{border-color:var(--oxide);}
23057    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
23058    .page-header{text-align:center;margin-bottom:16px;}
23059    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
23060    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
23061    /* Cards */
23062    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
23063    .option-card-wrap{position:relative;}
23064    .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;}
23065    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
23066    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
23067    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
23068    .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;}
23069    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
23070    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
23071    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
23072    .card-top-row{display:flex;align-items:center;gap:20px;}
23073    /* Two-column layout inside each card */
23074    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
23075    .card-left{display:flex;align-items:flex-start;min-width:0;}
23076    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
23077    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
23078    .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);}
23079    .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);}
23080    .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);}
23081    .card-text{min-width:0;}
23082    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
23083    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
23084    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
23085    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
23086    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
23087    /* Right CTA column */
23088    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
23089    .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;}
23090    /* Re-scan count badge */
23091    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
23092    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
23093    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
23094    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
23095    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
23096    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
23097    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
23098    body.dark-theme .btn-secondary{color:var(--oxide);}
23099    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
23100    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
23101    /* File input overlay — must be full-width so it aligns with other card-right buttons */
23102    .file-input-wrap{position:relative;width:100%;}
23103    .file-input-wrap .btn{width:100%;}
23104    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
23105    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23106    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
23107    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
23108    .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;}
23109    @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));}}
23110    /* Recent list (card 3 — full-width section below header) */
23111    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
23112    .recent-list{display:flex;flex-direction:column;gap:8px;}
23113    .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;}
23114    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
23115    .recent-item-info{flex:1;min-width:0;}
23116    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
23117    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
23118    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
23119    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
23120    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23121    .site-footer a{color:var(--muted);}
23122    @media(max-width:680px){
23123      .card-body{grid-template-columns:1fr;}
23124      .card-right{flex-direction:row;flex-wrap:wrap;}
23125      .btn{flex:1;}
23126    }
23127    .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;}
23128    .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;}
23129    .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;}
23130  </style>
23131</head>
23132<body>
23133  <div class="background-watermarks" aria-hidden="true">
23134    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23135    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23136    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23137    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23138    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23139    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23140    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
23141  </div>
23142  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23143  <div class="top-nav">
23144    <div class="top-nav-inner">
23145      <a class="brand" href="/">
23146        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23147        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23148      </a>
23149      <div class="nav-right">
23150        <a class="nav-pill" href="/">Home</a>
23151        <div class="nav-dropdown">
23152          <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>
23153          <div class="nav-dropdown-menu">
23154            <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>
23155          </div>
23156        </div>
23157        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
23158        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23159        <div class="nav-dropdown">
23160          <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>
23161          <div class="nav-dropdown-menu">
23162            <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>
23163          </div>
23164        </div>
23165        <div class="server-status-wrap" id="server-status-wrap">
23166          <div class="nav-pill server-online-pill" id="server-status-pill">
23167            <span class="status-dot" id="status-dot"></span>
23168            <span id="server-status-label">Server</span>
23169            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23170          </div>
23171          <div class="server-status-tip">
23172            OxideSLOC is running — accessible on your network.
23173            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23174          </div>
23175        </div>
23176        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23177          <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>
23178        </button>
23179        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
23180          <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>
23181          <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>
23182        </button>
23183      </div>
23184    </div>
23185  </div>
23186
23187  <div class="page">
23188    <div class="page-header">
23189      <h1>How would you like to scan?</h1>
23190      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
23191    </div>
23192
23193    <div class="option-grid">
23194
23195      <!-- Option 1: New scan -->
23196      <div class="option-card-wrap">
23197        <div class="option-card">
23198        <div class="option-icon new-scan">
23199          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
23200        </div>
23201        <div class="card-body">
23202          <div class="card-left">
23203            <div class="card-text">
23204              <div class="option-title">Start a new scan</div>
23205              <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>
23206              <ul class="feature-list">
23207                <li>Live project scope preview before you run</li>
23208                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
23209                <li>HTML, PDF, and JSON output — your choice</li>
23210              </ul>
23211            </div>
23212          </div>
23213          <div class="card-right">
23214            <a class="btn btn-primary" href="/scan">
23215              Configure &amp; scan
23216              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
23217            </a>
23218            <p class="card-tip">Full 4-step setup · all options</p>
23219          </div>
23220        </div>
23221        </div>
23222      </div>
23223
23224      <!-- Option 2: Load from config file -->
23225      <div class="option-card-wrap">
23226        <div class="option-card">
23227        <div class="option-icon load-config">
23228          <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>
23229        </div>
23230        <div class="card-body">
23231          <div class="card-left">
23232            <div class="card-text">
23233              <div class="option-title">Load a saved config</div>
23234              <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>
23235              <ul class="feature-list">
23236                <li>All 15 settings restored from the file</li>
23237                <li>Fully editable — change path or output dir</li>
23238                <li>Works with any scan-config.json</li>
23239              </ul>
23240            </div>
23241          </div>
23242          <div class="card-right">
23243            <div class="file-input-wrap">
23244              <button class="btn btn-secondary" id="load-config-btn" type="button">
23245                <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>
23246                Choose config file
23247              </button>
23248              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
23249            </div>
23250            <p class="card-tip" id="config-file-name">Exported after every scan</p>
23251          </div>
23252        </div>
23253        </div>
23254      </div>
23255
23256      <!-- Option 3: Re-scan recent project -->
23257      <div class="option-card-wrap">
23258        <div class="option-card" id="recent-card">
23259        <div class="card-top-row">
23260          <div class="option-icon rescan">
23261            <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>
23262          </div>
23263          <div class="card-body">
23264            <div class="card-left">
23265              <div class="card-text">
23266                <div class="option-title">Re-scan a recent project</div>
23267                <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>
23268                <ul class="feature-list">
23269                  <li>All 15+ settings restored from the saved config</li>
23270                  <li>Path and output dir are editable before running</li>
23271                  <li>Only scans with a saved config appear here</li>
23272                </ul>
23273              </div>
23274            </div>
23275            <div class="card-right">
23276              <div class="rescan-count-box">
23277                <div class="rescan-count-num" id="rescan-count-num">—</div>
23278                <div class="rescan-count-label">saved configs</div>
23279              </div>
23280              <a class="btn btn-secondary" href="/view-reports">
23281                <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>
23282                View all runs
23283              </a>
23284              <p class="card-tip">Opens run history</p>
23285            </div>
23286          </div>
23287        </div>
23288        <div class="section-divider"></div>
23289        <div class="recent-list" id="recent-list">
23290          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
23291        </div>
23292        </div>
23293      </div>
23294
23295    </div>
23296  </div>
23297
23298  <footer class="site-footer">
23299    local code analysis - metrics, history and reports
23300    &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>
23301    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
23302    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
23303    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
23304    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
23305  </footer>
23306
23307  <script nonce="{{ csp_nonce }}">
23308    (function () {
23309      var storageKey = 'oxide-sloc-theme';
23310      var body = document.body;
23311      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
23312      var toggle = document.getElementById('theme-toggle');
23313      if (toggle) toggle.addEventListener('click', function () {
23314        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
23315        body.classList.toggle('dark-theme', next === 'dark');
23316        try { localStorage.setItem(storageKey, next); } catch(e) {}
23317      });
23318
23319      (function randomizeWatermarks() {
23320        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
23321        if (!wms.length) return;
23322        var placed = [];
23323        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; }
23324        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]; }
23325        var half = Math.floor(wms.length / 2);
23326        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; });
23327      })();
23328      (function spawnCodeParticles() {
23329        var container = document.getElementById('code-particles');
23330        if (!container) return;
23331        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'];
23332        var count = 38;
23333        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); }
23334      })();
23335      // Recent scans data injected from server
23336      var recentScans = {{ recent_scans_json|safe }};
23337
23338      function configToParams(cfg) {
23339        var p = new URLSearchParams();
23340        p.set('prefilled', '1');
23341        if (cfg.path) p.set('path', cfg.path);
23342        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23343        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23344        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23345        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23346        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23347        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23348        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23349        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23350        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23351        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23352        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23353        if (cfg.report_title) p.set('report_title', cfg.report_title);
23354        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23355        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23356        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23357        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23358        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23359        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23360        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23361        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23362        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23363        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23364        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23365        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23366        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23367        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23368        return p;
23369      }
23370
23371      // Build recent scan list (capped at 3 visible entries)
23372      var list = document.getElementById('recent-list');
23373      var noNote = document.getElementById('no-recent-note');
23374      var hasAny = false;
23375      var MAX_RECENT = 3;
23376      if (Array.isArray(recentScans)) {
23377        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23378        var shown = 0;
23379        validEntries.forEach(function (entry) {
23380          if (shown >= MAX_RECENT) return;
23381          shown++;
23382          hasAny = true;
23383          var item = document.createElement('div');
23384          item.className = 'recent-item';
23385          item.title = 'Restore all settings and open wizard';
23386          item.innerHTML =
23387            '<div class="recent-item-info">' +
23388              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23389              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
23390            '</div>' +
23391            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23392          item.addEventListener('click', function () {
23393            var params = configToParams(entry.config);
23394            window.location.href = '/scan?' + params.toString();
23395          });
23396          list.appendChild(item);
23397        });
23398        if (validEntries.length > MAX_RECENT) {
23399          var moreEl = document.createElement('div');
23400          moreEl.className = 'recent-more-link';
23401          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
23402          list.appendChild(moreEl);
23403        }
23404      }
23405      if (hasAny && noNote) noNote.style.display = 'none';
23406      // Update count badge
23407      var countEl = document.getElementById('rescan-count-num');
23408      if (countEl) {
23409        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23410        countEl.textContent = total > 0 ? total : '0';
23411      }
23412
23413      // Config file loader
23414      var fileInput = document.getElementById('config-file-input');
23415      var fileName = document.getElementById('config-file-name');
23416      var loadBtn = document.getElementById('load-config-btn');
23417      // Wire the visible button to open the hidden file picker.
23418      if (loadBtn && fileInput) {
23419        loadBtn.addEventListener('click', function () { fileInput.click(); });
23420      }
23421      if (fileInput) {
23422        fileInput.addEventListener('change', function () {
23423          var file = fileInput.files && fileInput.files[0];
23424          if (!file) return;
23425          if (fileName) fileName.textContent = '\u2713 ' + file.name;
23426          var reader = new FileReader();
23427          reader.onload = function (e) {
23428            try {
23429              var cfg = JSON.parse(e.target.result);
23430              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23431              var params = configToParams(cfg);
23432              window.location.href = '/scan?' + params.toString();
23433            } catch (err) {
23434              alert('Could not parse config file: ' + err.message);
23435            }
23436          };
23437          reader.readAsText(file);
23438        });
23439      }
23440
23441      function escHtml(s) {
23442        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
23443      }
23444    })();
23445  </script>
23446  <script nonce="{{ csp_nonce }}">
23447  (function(){
23448    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'}];
23449    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);});}
23450    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23451    function init(){
23452      var btn=document.getElementById('settings-btn');if(!btn)return;
23453      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23454      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>';
23455      document.body.appendChild(m);
23456      var g=document.getElementById('scheme-grid');
23457      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);});
23458      var cl=document.getElementById('settings-close');
23459      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);});})();
23460      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');});
23461      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23462      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23463    }
23464    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23465  }());
23466  </script>
23467  <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]';
23468  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;}
23469  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>
23470</body>
23471</html>
23472"##,
23473    ext = "html"
23474)]
23475struct ScanSetupTemplate {
23476    version: &'static str,
23477    recent_scans_json: String,
23478    csp_nonce: String,
23479}
23480
23481#[derive(Template)]
23482#[template(
23483    source = r##"
23484<!doctype html>
23485<html lang="en">
23486<head>
23487  <meta charset="utf-8">
23488  <meta name="viewport" content="width=device-width, initial-scale=1">
23489  <title>OxideSLOC | {{ report_title }} | Report</title>
23490  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23491  <style nonce="{{ csp_nonce }}">
23492    :root {
23493      --radius: 18px;
23494      --bg: #f5efe8;
23495      --surface: rgba(255,255,255,0.82);
23496      --surface-2: #fbf7f2;
23497      --surface-3: #efe6dc;
23498      --line: #e6d0bf;
23499      --line-strong: #dcb89f;
23500      --text: #43342d;
23501      --muted: #7b675b;
23502      --muted-2: #a08777;
23503      --nav: #b85d33;
23504      --nav-2: #7a371b;
23505      --accent: #6f9bff;
23506      --accent-2: #4a78ee;
23507      --oxide: #d37a4c;
23508      --oxide-2: #b35428;
23509      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23510      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23511      --success-bg: #e8f5ed;
23512      --success-text: #1a8f47;
23513      --info-bg: #eef3ff;
23514      --info-text: #4467d8;
23515    }
23516
23517    body.dark-theme {
23518      --bg: #1b1511;
23519      --surface: #261c17;
23520      --surface-2: #2d221d;
23521      --surface-3: #372922;
23522      --line: #524238;
23523      --line-strong: #6c5649;
23524      --text: #f5ece6;
23525      --muted: #c7b7aa;
23526      --muted-2: #aa9485;
23527      --nav: #b85d33;
23528      --nav-2: #7a371b;
23529      --accent: #6f9bff;
23530      --accent-2: #4a78ee;
23531      --oxide: #d37a4c;
23532      --oxide-2: #b35428;
23533      --shadow: 0 18px 42px rgba(0,0,0,0.28);
23534      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23535      --success-bg: #163927;
23536      --success-text: #8fe2a8;
23537      --info-bg: #1c2847;
23538      --info-text: #a9c1ff;
23539    }
23540
23541    * { box-sizing: border-box; }
23542    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); }
23543    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23544    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23545    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23546    .top-nav, .page { position: relative; z-index: 2; }
23547    .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); }
23548    .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; }
23549    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23550    .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)); }
23551    .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; }
23552    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23553    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23554    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23555    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23556    .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; }
23557    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23558    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23559    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23560    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23561    @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; } }
23562    .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; }
23563    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23564    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23565    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23566    .theme-toggle .icon-sun { display:none; }
23567    body.dark-theme .theme-toggle .icon-sun { display:block; }
23568    body.dark-theme .theme-toggle .icon-moon { display:none; }
23569    .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;}
23570    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23571    .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);}
23572    .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;}
23573    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23574    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23575    .settings-modal-body{padding:14px 16px 16px;}
23576    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23577    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23578    .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;}
23579    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23580    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23581    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23582    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23583    .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;}
23584    .tz-select:focus{border-color:var(--oxide);}
23585    .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; }
23586    .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;}
23587    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23588    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23589    .hero, .panel { padding: 22px; }
23590    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23591    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23592    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23593    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23594    .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; }
23595    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23596    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23597    .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; }
23598    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23599    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23600    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23601    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23602    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23603    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23604    .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); }
23605    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23606    .delta-card-val { font-size:16px; font-weight:800; }
23607    .delta-card-val.pos { color:#1e7e34; }
23608    .delta-card-val.neg { color:var(--neg); }
23609    .delta-card-val.mod { color:#b35428; }
23610    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23611    .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; }
23612    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23613    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23614    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23615    .compare-ts { font-size:13px; color:var(--muted); }
23616    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23617    .compare-arrow { color: var(--muted); }
23618    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23619    .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; }
23620    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23621    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23622    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23623    .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; }
23624    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23625    .run-mgmt-card .action-buttons { justify-content:center; }
23626    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23627    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23628    .button, .copy-button {
23629      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;
23630    }
23631    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23632    @keyframes spin { to { transform: rotate(360deg); } }
23633    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23634    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23635    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23636    .path-item strong { display: block; margin-bottom: 6px; }
23637    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23638    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23639    .path-subitem { flex: 1; }
23640    .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); }
23641    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); }
23642    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23643    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23644    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23645    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23646    th { color: var(--muted); font-weight: 700; }
23647    tr:last-child td { border-bottom: none; }
23648    #subm-tbl col:nth-child(1){width:15%;}
23649    #subm-tbl col:nth-child(2){width:31%;}
23650    #subm-tbl col:nth-child(3){width:9%;}
23651    #subm-tbl col:nth-child(4){width:9%;}
23652    #subm-tbl col:nth-child(5){width:9%;}
23653    #subm-tbl col:nth-child(6){width:9%;}
23654    #subm-tbl col:nth-child(7){width:9%;}
23655    #subm-tbl col:nth-child(8){width:9%;}
23656    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23657    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23658    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23659    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23660    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23661    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23662    .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; }
23663    .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; }
23664    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23665    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23666    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23667    .muted { color: var(--muted); }
23668    /* Run-ID chip row (mirrors HTML report) */
23669    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23670    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23671    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23672    .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; }
23673    .run-id-chip[data-copy] { cursor:pointer; }
23674    a.run-id-chip { text-decoration:none; cursor:pointer; }
23675    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23676    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23677    .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; }
23678    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23679    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23680    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23681    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23682    a.commit-link-value { color:inherit; text-decoration:none; }
23683    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23684    .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; }
23685    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23686    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23687    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23688    .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; }
23689    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23690    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23691    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23692    /* Meta chips row */
23693    .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%; }
23694    .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; }
23695    .meta-chip:last-child { border-right:none; }
23696    .meta-chip b { color:var(--text); font-weight:700; }
23697    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23698    .site-footer a{color:var(--muted);}
23699    .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; }
23700    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23701    .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; }
23702    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23703    /* Stat chips (matches HTML report) */
23704    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23705    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23706    /* Hero stat strip: uniform grid where every card is the same width and the
23707       columns line up across both rows. JS sets the column count to ceil(n/2) so
23708       the cards always occupy exactly two rows; when the count is odd the last
23709       card spans two columns to fill the trailing cell with no empty gap. */
23710    .summary-strip-hero { align-items:stretch; }
23711    .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; }
23712    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23713    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23714    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23715    .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; }
23716    .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); }
23717    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23718    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23719    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23720    .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; }
23721    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23722    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23723    .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); }
23724    .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); }
23725    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23726    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23727    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23728    /* Submodule panel */
23729    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23730    /* Metrics tables stack */
23731    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23732    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23733    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23734    .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)); }
23735    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23736    /* Metrics table */
23737    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23738    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23739    .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; }
23740    .metrics-table thead th:not(:first-child) { text-align: right; }
23741    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23742    .metrics-table tbody tr:last-child td { border-bottom: none; }
23743    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23744    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23745    .metrics-table tbody tr:hover td { background: var(--surface-2); }
23746    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23747    .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; }
23748    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23749    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23750    .mt-val-pos { color: var(--pos); font-weight: 700; }
23751    .mt-val-neg { color: var(--neg); font-weight: 700; }
23752    .mt-val-zero { color: var(--muted); }
23753    .mt-val-mod { color: var(--oxide-2); }
23754    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23755    @media (max-width: 1180px) {
23756      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23757      .nav-project-slot, .nav-status { justify-content:flex-start; }
23758      .hero-top { flex-direction: column; }
23759      .run-mgmt-strip { flex-direction: column; }
23760    }
23761    .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;}
23762    @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));}}
23763    .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;}
23764    /* ── Result-page chart controls ─────────────────────────────────────────── */
23765    .r-chart-section{margin-bottom:24px;}
23766    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23767    .section-pair > .panel{flex-shrink:0;}
23768    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23769    .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;}
23770    .r-chart-select:focus{border-color:var(--accent);}
23771    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23772    .r-chart-container svg{display:block;width:100%;height:auto;}
23773    .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;}
23774    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23775    .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;}
23776    .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);}
23777    .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;}
23778    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23779    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23780    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23781    .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;}
23782    .r-chart-modal-close:hover{opacity:.7;}
23783    body.dark-theme .r-chart-modal{background:var(--surface);}
23784    .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;}
23785    .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);}
23786    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23787    .lang-bar-row:hover{transform:translateY(-2px);}
23788    .lang-bar-row .rchit:hover{filter:none;transform:none;}
23789    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23790    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23791    .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;}
23792    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23793    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23794    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23795    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23796    #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;}
23797    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23798    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23799    .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;}
23800    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23801    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23802    .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;}
23803    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23804    .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;}
23805    .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;}
23806    body.has-report-banner .top-nav{top:27px;}
23807    body.has-report-banner{padding-bottom:27px;}
23808  </style>
23809</head>
23810<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23811  <div class="background-watermarks" aria-hidden="true">
23812    <img src="/images/logo/logo-text.png" alt="" />
23813    <img src="/images/logo/logo-text.png" alt="" />
23814    <img src="/images/logo/logo-text.png" alt="" />
23815    <img src="/images/logo/logo-text.png" alt="" />
23816    <img src="/images/logo/logo-text.png" alt="" />
23817    <img src="/images/logo/logo-text.png" alt="" />
23818    <img src="/images/logo/logo-text.png" alt="" />
23819    <img src="/images/logo/logo-text.png" alt="" />
23820    <img src="/images/logo/logo-text.png" alt="" />
23821    <img src="/images/logo/logo-text.png" alt="" />
23822    <img src="/images/logo/logo-text.png" alt="" />
23823    <img src="/images/logo/logo-text.png" alt="" />
23824    <img src="/images/logo/logo-text.png" alt="" />
23825    <img src="/images/logo/logo-text.png" alt="" />
23826  </div>
23827  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23828  {% if let Some(banner) = report_header_footer %}
23829  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23830  {% endif %}
23831  <div class="top-nav">
23832    <div class="top-nav-inner">
23833      <a class="brand" href="/">
23834        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23835        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23836      </a>
23837      <div class="nav-project-slot">
23838        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23839      </div>
23840      <div class="nav-status">
23841        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23842        <div class="nav-dropdown">
23843          <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>
23844          <div class="nav-dropdown-menu">
23845            <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>
23846          </div>
23847        </div>
23848        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23849        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23850        <div class="nav-dropdown">
23851          <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>
23852          <div class="nav-dropdown-menu">
23853            <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>
23854          </div>
23855        </div>
23856        <div class="server-status-wrap" id="server-status-wrap">
23857          <div class="nav-pill server-online-pill" id="server-status-pill">
23858            <span class="status-dot" id="status-dot"></span>
23859            <span id="server-status-label">Server</span>
23860            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23861          </div>
23862          <div class="server-status-tip">
23863            OxideSLOC is running — accessible on your network.
23864            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23865          </div>
23866        </div>
23867        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23868          <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>
23869        </button>
23870        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23871          <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>
23872          <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>
23873        </button>
23874      </div>
23875    </div>
23876  </div>
23877
23878  <div class="page">
23879    <section class="hero">
23880      <div class="hero-top">
23881        <div>
23882          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23883            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23884            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23885            <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>
23886          </div>
23887        </div>
23888        <div class="hero-quick-actions">
23889          {% if server_mode %}
23890          <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>
23891          {% else %}
23892          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23893          {% endif %}
23894          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23895          {% if !server_mode %}
23896          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23897          {% endif %}
23898          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23899          <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>
23900        </div>
23901      </div>
23902
23903      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23904      <div class="run-id-row">
23905        <span class="run-id-chip" data-copy="{{ run_id }}">
23906          <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>
23907          <span class="run-id-chip-value">{{ run_id }}</span>
23908          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23909        </span>
23910        {% match git_commit_long %}
23911          {% when Some with (long_sha) %}
23912          {% match git_commit_url %}
23913            {% when Some with (commit_url) %}
23914            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23915              <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>
23916              <span class="run-id-chip-value">{{ long_sha }}</span>
23917              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23918            </a>
23919            {% when None %}
23920            <span class="run-id-chip" data-copy="{{ long_sha }}">
23921              <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>
23922              <span class="run-id-chip-value">{{ long_sha }}</span>
23923              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23924            </span>
23925          {% endmatch %}
23926          {% when None %}
23927          <span class="run-id-chip muted-chip">
23928            <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>
23929            <span class="run-id-chip-value">Not detected</span>
23930            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23931          </span>
23932        {% endmatch %}
23933        {% match git_branch %}
23934          {% when Some with (branch) %}
23935          {% match git_branch_url %}
23936            {% when Some with (branch_url) %}
23937            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23938              <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>
23939              <span class="run-id-chip-value">{{ branch }}</span>
23940              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23941            </a>
23942            {% when None %}
23943            <span class="run-id-chip">
23944              <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>
23945              <span class="run-id-chip-value">{{ branch }}</span>
23946              <span class="chip-tooltip">Git branch active at scan time</span>
23947            </span>
23948          {% endmatch %}
23949          {% when None %}
23950          <span class="run-id-chip muted-chip">
23951            <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>
23952            <span class="run-id-chip-value">Not detected</span>
23953            <span class="chip-tooltip">No Git branch was found for this scan</span>
23954          </span>
23955        {% endmatch %}
23956        {% match git_author %}
23957          {% when Some with (author) %}
23958          <span class="run-id-chip" data-author="{{ author }}">
23959            <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>
23960            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23961            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23962          </span>
23963          {% when None %}
23964          <span class="run-id-chip muted-chip">
23965            <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>
23966            <span class="run-id-chip-value">Not detected</span>
23967            <span class="chip-tooltip">No commit author was found for this scan</span>
23968          </span>
23969        {% endmatch %}
23970      </div>
23971
23972      <!-- Scan metadata row -->
23973      <div class="meta">
23974        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23975        <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23976        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23977        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23978        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23979      </div>
23980
23981      <!-- All summary stat chips in one unified strip (8 columns) -->
23982      <div class="summary-strip summary-strip-hero">
23983        <div class="stat-chip" data-raw="{{ physical_lines }}">
23984          <div class="stat-chip-label">Physical lines</div>
23985          <div class="stat-chip-val">{{ physical_lines }}</div>
23986          <div class="stat-chip-exact"></div>
23987          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23988        </div>
23989        <div class="stat-chip" data-raw="{{ code_lines }}">
23990          <div class="stat-chip-label">Code</div>
23991          <div class="stat-chip-val">{{ code_lines }}</div>
23992          <div class="stat-chip-exact"></div>
23993          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23994        </div>
23995        <div class="stat-chip" data-raw="{{ comment_lines }}">
23996          <div class="stat-chip-label">Comments</div>
23997          <div class="stat-chip-val">{{ comment_lines }}</div>
23998          <div class="stat-chip-exact"></div>
23999          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
24000        </div>
24001        <div class="stat-chip" data-raw="{{ blank_lines }}">
24002          <div class="stat-chip-label">Blank</div>
24003          <div class="stat-chip-val">{{ blank_lines }}</div>
24004          <div class="stat-chip-exact"></div>
24005          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
24006        </div>
24007        <div class="stat-chip" data-raw="{{ mixed_lines }}">
24008          <div class="stat-chip-label">Mixed separate</div>
24009          <div class="stat-chip-val">{{ mixed_lines }}</div>
24010          <div class="stat-chip-exact"></div>
24011          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
24012        </div>
24013        <div class="stat-chip" data-raw="{{ functions }}">
24014          <div class="stat-chip-label">Functions</div>
24015          <div class="stat-chip-val">{{ functions }}</div>
24016          <div class="stat-chip-exact"></div>
24017          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
24018        </div>
24019        <div class="stat-chip" data-raw="{{ classes }}">
24020          <div class="stat-chip-label">Classes / Types</div>
24021          <div class="stat-chip-val">{{ classes }}</div>
24022          <div class="stat-chip-exact"></div>
24023          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
24024        </div>
24025        <div class="stat-chip" data-raw="{{ variables }}">
24026          <div class="stat-chip-label">Variables</div>
24027          <div class="stat-chip-val">{{ variables }}</div>
24028          <div class="stat-chip-exact"></div>
24029          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
24030        </div>
24031        <div class="stat-chip" data-raw="{{ imports }}">
24032          <div class="stat-chip-label">Imports</div>
24033          <div class="stat-chip-val">{{ imports }}</div>
24034          <div class="stat-chip-exact"></div>
24035          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
24036        </div>
24037        <div class="stat-chip" data-raw="{{ test_count }}">
24038          <div class="stat-chip-label">Tests</div>
24039          <div class="stat-chip-val">{{ test_count }}</div>
24040          <div class="stat-chip-exact"></div>
24041          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
24042        </div>
24043        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
24044          <div class="stat-chip-label">Code density</div>
24045          <div class="stat-chip-val stat-chip-density-val">—</div>
24046          <div class="stat-chip-exact"></div>
24047          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
24048        </div>
24049        <div class="stat-chip" data-raw="{{ files_analyzed }}">
24050          <div class="stat-chip-label">Files analyzed</div>
24051          <div class="stat-chip-val">{{ files_analyzed }}</div>
24052          <div class="stat-chip-exact"></div>
24053          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
24054        </div>
24055        {% if cyclomatic_complexity > 0 %}
24056        <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 %}>
24057          <div class="stat-chip-label">Complexity score</div>
24058          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
24059          <div class="stat-chip-exact"></div>
24060          <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>
24061        </div>
24062        {% endif %}
24063        {% if let Some(ls) = lsloc %}
24064        <div class="stat-chip" data-raw="{{ ls }}">
24065          <div class="stat-chip-label">Logical SLOC</div>
24066          <div class="stat-chip-val">{{ ls }}</div>
24067          <div class="stat-chip-exact"></div>
24068          <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>
24069        </div>
24070        {% endif %}
24071        {% if uloc > 0 %}
24072        <div class="stat-chip" data-raw="{{ uloc }}">
24073          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
24074          <div class="stat-chip-val">{{ uloc }}</div>
24075          <div class="stat-chip-exact"></div>
24076          <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>
24077        </div>
24078        {% endif %}
24079        {% if uloc > 0 && dryness_pct_str != "" %}
24080        <div class="stat-chip">
24081          <div class="stat-chip-label">DRYness</div>
24082          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
24083          <div class="stat-chip-exact"></div>
24084          <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>
24085        </div>
24086        {% endif %}
24087        {% if duplicate_group_count > 0 %}
24088        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
24089          <div class="stat-chip-label">Duplicate groups</div>
24090          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
24091          <div class="stat-chip-exact"></div>
24092          <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>
24093        </div>
24094        {% endif %}
24095        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
24096             odd, so the strip always forms exactly two full rows with every column
24097             aligned and every card the same width (no oversized card, no gap). -->
24098        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
24099          <div class="stat-chip-label">Assertions</div>
24100          <div class="stat-chip-val">{{ test_assertion_count }}</div>
24101          <div class="stat-chip-exact"></div>
24102          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
24103        </div>
24104      </div>
24105
24106      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
24107      <div class="compare-banner">
24108        <div class="compare-banner-body">
24109          <div class="compare-banner-top">
24110          <div class="compare-banner-meta">
24111            <span class="compare-label">Previous scan</span>
24112            <span class="compare-ts">{{ prev_ts }}</span>
24113            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
24114            {% if let Some(prev_code) = prev_run_code_lines %}
24115            <div class="compare-banner-stats" style="margin-top:4px;">
24116              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
24117              <span class="compare-arrow">→</span>
24118              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
24119              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
24120              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
24121            </div>
24122            {% endif %}
24123          </div>
24124          {% if delta_lines_added.is_some() %}
24125          <div class="delta-cards-inline">
24126            <div class="delta-card-inline">
24127              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
24128              <div class="delta-card-lbl">lines added</div>
24129              <div class="delta-card-tip">Code lines added since the previous scan</div>
24130            </div>
24131            <div class="delta-card-inline">
24132              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
24133              <div class="delta-card-lbl">lines removed</div>
24134              <div class="delta-card-tip">Code lines removed since the previous scan</div>
24135            </div>
24136            <div class="delta-card-inline">
24137              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
24138              <div class="delta-card-lbl">unmodified lines</div>
24139              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
24140            </div>
24141            <div class="delta-card-inline">
24142              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
24143              <div class="delta-card-lbl">files modified</div>
24144              <div class="delta-card-tip">Files with at least one line changed</div>
24145            </div>
24146            <div class="delta-card-inline">
24147              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
24148              <div class="delta-card-lbl">files added</div>
24149              <div class="delta-card-tip">New files added since the previous scan</div>
24150            </div>
24151            <div class="delta-card-inline">
24152              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
24153              <div class="delta-card-lbl">files removed</div>
24154              <div class="delta-card-tip">Files deleted since the previous scan</div>
24155            </div>
24156            <div class="delta-card-inline">
24157              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
24158              <div class="delta-card-lbl">files unchanged</div>
24159              <div class="delta-card-tip">Files with no changes since the previous scan</div>
24160            </div>
24161            <div class="delta-card-inline">
24162              <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
24163              <div class="delta-card-lbl">files total</div>
24164              <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
24165            </div>
24166          </div>
24167          {% else %}
24168          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
24169            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
24170          </p>
24171          {% endif %}
24172          </div>
24173          <div class="compare-banner-actions">
24174            <div class="compare-banner-actions-left">
24175              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
24176              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
24177            </div>
24178            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
24179          </div>
24180        </div>
24181      </div>
24182      {% endif %}{% endif %}
24183
24184      <div class="action-grid">
24185        <div class="action-card">
24186          <h3>HTML report</h3>
24187          <div class="action-buttons">
24188            {% match html_url %}
24189              {% when Some with (url) %}
24190                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
24191              {% when None %}{% endmatch %}
24192            {% match html_download_url %}
24193              {% when Some with (url) %}
24194                <a class="button secondary" href="{{ url }}">Download HTML</a>
24195              {% when None %}{% endmatch %}
24196            {% match html_path %}
24197              {% when Some with (_path) %}{% when None %}{% endmatch %}
24198            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
24199          </div>
24200        </div>
24201        <div class="action-card">
24202          <h3>PDF report</h3>
24203          <div class="action-buttons">
24204            {% match pdf_url %}
24205              {% when Some with (url) %}
24206                {% if pdf_generating %}
24207                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
24208                    <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>
24209                    Generating PDF…
24210                  </button>
24211                {% else %}
24212                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
24213                {% endif %}
24214              {% when None %}
24215                {% match html_url %}
24216                  {% when Some with (_hurl) %}
24217                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
24218                    <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>
24219                  {% when None %}
24220                    <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;">
24221                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
24222                    </p>
24223                {% endmatch %}
24224            {% endmatch %}
24225            {% match pdf_download_url %}
24226              {% when Some with (url) %}
24227                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
24228              {% when None %}{% endmatch %}
24229            {% match pdf_url %}
24230              {% when Some with (_) %}
24231                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
24232              {% when None %}{% endmatch %}
24233          </div>
24234        </div>
24235        <div class="action-card">
24236          <h3>JSON result</h3>
24237          <div class="action-buttons">
24238            {% match json_url %}
24239              {% when Some with (url) %}
24240                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
24241              {% when None %}{% endmatch %}
24242            {% match json_download_url %}
24243              {% when Some with (url) %}
24244                <a class="button secondary" href="{{ url }}">Download JSON</a>
24245              {% when None %}{% endmatch %}
24246            {% match json_path %}
24247              {% when Some with (_path) %}
24248                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
24249              {% when None %}
24250                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
24251              {% endmatch %}
24252          </div>
24253        </div>
24254        <div class="action-card">
24255          <h3>Scan config</h3>
24256          <div class="action-buttons">
24257            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
24258            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
24259            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
24260          </div>
24261        </div>
24262        {% if confluence_configured %}
24263        <div class="action-card" id="confluenceCard">
24264          <h3>Confluence</h3>
24265          <div class="action-buttons">
24266            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
24267            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
24268          </div>
24269          <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>
24270        </div>
24271        {% endif %}
24272      </div>
24273      {% if confluence_configured %}
24274      <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;">
24275        <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);">
24276          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
24277          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
24278          <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;">
24279          <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>
24280          <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;">
24281          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
24282          <div style="display:flex;gap:10px;justify-content:flex-end;">
24283            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
24284            <button class="button" id="confSubmitBtn" type="button">Post</button>
24285          </div>
24286        </div>
24287      </div>
24288      {% endif %}
24289      <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;">
24290        <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);">
24291          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
24292          <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>
24293          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
24294          <div style="display:flex;gap:18px;justify-content:flex-end;">
24295            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
24296            <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>
24297          </div>
24298        </div>
24299      </div>
24300      {% if !submodule_rows.is_empty() %}
24301      <div class="submodule-panel">
24302        <div class="toolbar-row">
24303          <div>
24304            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
24305            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
24306          </div>
24307          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
24308        </div>
24309        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
24310        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
24311          <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>
24312          <thead>
24313            <tr>
24314              <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>
24315              <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>
24316              <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>
24317              <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>
24318              <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>
24319              <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>
24320              <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>
24321              <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>
24322            </tr>
24323          </thead>
24324          <tbody>
24325            {% for row in submodule_rows %}
24326            <tr>
24327              <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>
24328              <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>
24329              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24330              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24331              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24332              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24333              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24334              <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>
24335            </tr>
24336            {% endfor %}
24337          </tbody>
24338        </table>
24339        </div>
24340      </div>
24341      {% endif %}
24342
24343      <div class="metrics-tables-stack">
24344
24345        <div class="metrics-table-wrap">
24346          <div class="metrics-table-title">Files</div>
24347          <table class="metrics-table">
24348            <thead>
24349              <tr>
24350                <th>Metric</th>
24351                <th>This Run</th>
24352                <th>Previous</th>
24353                <th>Change</th>
24354              </tr>
24355            </thead>
24356            <tbody>
24357              <tr>
24358                <td>Files analyzed</td>
24359                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24360                <td>{{ prev_fa_str|commas }}</td>
24361                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24362              </tr>
24363              <tr>
24364                <td>Files skipped</td>
24365                <td>{{ files_skipped|commas }}</td>
24366                <td>{{ prev_fs_str|commas }}</td>
24367                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24368              </tr>
24369              <tr>
24370                <td>Files modified</td>
24371                <td class="mt-val-na">—</td>
24372                <td class="mt-val-na">—</td>
24373                <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>
24374              </tr>
24375              <tr>
24376                <td>Files unchanged</td>
24377                <td class="mt-val-na">—</td>
24378                <td class="mt-val-na">—</td>
24379                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24380              </tr>
24381              <tr>
24382                <td>Files total</td>
24383                <td class="mt-val-na">—</td>
24384                <td class="mt-val-na">—</td>
24385                <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24386              </tr>
24387            </tbody>
24388          </table>
24389        </div>
24390
24391        <div class="metrics-table-wrap">
24392          <div class="metrics-table-title">Line Counts</div>
24393          <table class="metrics-table">
24394            <thead>
24395              <tr>
24396                <th>Metric</th>
24397                <th>This Run</th>
24398                <th>Previous</th>
24399                <th>Change</th>
24400              </tr>
24401            </thead>
24402            <tbody>
24403              <tr>
24404                <td>Physical lines</td>
24405                <td class="mt-val-large">{{ physical_lines|commas }}</td>
24406                <td>{{ prev_pl_str|commas }}</td>
24407                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24408              </tr>
24409              <tr>
24410                <td>Code lines</td>
24411                <td class="mt-val-large">{{ code_lines|commas }}</td>
24412                <td>{{ prev_cl_str|commas }}</td>
24413                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24414              </tr>
24415              <tr>
24416                <td>Comment lines</td>
24417                <td>{{ comment_lines|commas }}</td>
24418                <td>{{ prev_cml_str|commas }}</td>
24419                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24420              </tr>
24421              <tr>
24422                <td>Blank lines</td>
24423                <td>{{ blank_lines|commas }}</td>
24424                <td>{{ prev_bl_str|commas }}</td>
24425                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24426              </tr>
24427              <tr>
24428                <td>Mixed (separate)</td>
24429                <td>{{ mixed_lines|commas }}</td>
24430                <td class="mt-val-na">—</td>
24431                <td class="mt-val-na">—</td>
24432              </tr>
24433            </tbody>
24434          </table>
24435        </div>
24436
24437        <div class="metrics-tables-lower">
24438          <div class="metrics-table-wrap">
24439            <div class="metrics-table-title">Code Structure</div>
24440            <table class="metrics-table">
24441              <thead>
24442                <tr>
24443                  <th>Metric</th>
24444                  <th>This Run</th>
24445                </tr>
24446              </thead>
24447              <tbody>
24448                <tr>
24449                  <td>Functions</td>
24450                  <td>{{ functions|commas }}</td>
24451                </tr>
24452                <tr>
24453                  <td>Classes / Types</td>
24454                  <td>{{ classes|commas }}</td>
24455                </tr>
24456                <tr>
24457                  <td>Variables</td>
24458                  <td>{{ variables|commas }}</td>
24459                </tr>
24460                <tr>
24461                  <td>Imports</td>
24462                  <td>{{ imports|commas }}</td>
24463                </tr>
24464              </tbody>
24465            </table>
24466          </div>
24467
24468          <div class="metrics-table-wrap">
24469            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24470            <table class="metrics-table">
24471              <thead>
24472                <tr>
24473                  <th>Metric</th>
24474                  <th>Change</th>
24475                </tr>
24476              </thead>
24477              <tbody>
24478                <tr>
24479                  <td>Lines added</td>
24480                  <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>
24481                </tr>
24482                <tr>
24483                  <td>Lines removed</td>
24484                  <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>
24485                </tr>
24486                <tr>
24487                  <td>Lines modified (net)</td>
24488                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24489                </tr>
24490                <tr>
24491                  <td>Lines unmodified</td>
24492                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24493                </tr>
24494              </tbody>
24495            </table>
24496          </div>
24497        </div>
24498
24499      </div>
24500
24501      <div class="path-list">
24502        <div class="path-item">
24503          <div class="path-item-label">Project path</div>
24504          {% 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 %}
24505        </div>
24506        <div class="path-item">
24507          <div class="path-item-label">Git branch</div>
24508          {% if let Some(branch) = git_branch %}
24509          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24510          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24511          {% else %}
24512          <code style="color:var(--muted)">—</code>
24513          {% endif %}
24514        </div>
24515        <div class="path-item">
24516          <div class="path-item-label">Output folder</div>
24517          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24518        </div>
24519        <div class="path-item">
24520          <div class="path-item-label">Run ID</div>
24521          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24522            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24523            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24524          </div>
24525        </div>
24526      </div>
24527    </section>
24528
24529    {% if has_cocomo %}
24530    <div class="cocomo-box" style="margin-top:24px;">
24531      <div class="cocomo-box-head">
24532        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
24533        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24534          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24535          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24536        </span>
24537      </div>
24538      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24539        <div class="stat-chip">
24540          <div class="stat-chip-label">Person-months</div>
24541          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24542          <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>
24543        </div>
24544        <div class="stat-chip">
24545          <div class="stat-chip-label">Schedule (months)</div>
24546          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24547          <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>
24548        </div>
24549        <div class="stat-chip">
24550          <div class="stat-chip-label">Avg. Team Size</div>
24551          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24552          <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>
24553        </div>
24554        <div class="stat-chip">
24555          <div class="stat-chip-label">Input KSLOC</div>
24556          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24557          <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>
24558        </div>
24559      </div>
24560      <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>
24561    </div>
24562    {% endif %}
24563
24564    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24565    <div class="cocomo-box" style="margin-top:24px;">
24566      <div class="cocomo-box-head">
24567        <span class="cocomo-box-title">Tests &amp; Coverage</span>
24568        {% if has_coverage_data %}
24569        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24570          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24571        </span>
24572        {% endif %}
24573      </div>
24574      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24575        <div class="stat-chip">
24576          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24577          <div class="stat-chip-label">Test Functions</div>
24578          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24579        </div>
24580        <div class="stat-chip">
24581          {% if has_coverage_data %}
24582          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24583          {% else %}
24584          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24585          {% endif %}
24586          <div class="stat-chip-label">Line Coverage</div>
24587          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24588        </div>
24589        <div class="stat-chip">
24590          {% if !cov_fn_pct.is_empty() %}
24591          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24592          {% else %}
24593          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24594          {% endif %}
24595          <div class="stat-chip-label">Fn Coverage</div>
24596          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24597        </div>
24598        <div class="stat-chip">
24599          {% if !cov_branch_pct.is_empty() %}
24600          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24601          {% else %}
24602          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24603          {% endif %}
24604          <div class="stat-chip-label">Branch Coverage</div>
24605          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24606        </div>
24607      </div>
24608      {% if has_coverage_data %}
24609      <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>
24610      {% else %}
24611      <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>
24612      {% endif %}
24613    </div>
24614
24615    <div class="section-pair">
24616    <section class="panel">
24617        <div class="toolbar-row">
24618          <div>
24619            <h2>Language Breakdown</h2>
24620            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24621          </div>
24622          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24623        </div>
24624        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24625    </section>
24626
24627    <section class="panel r-chart-section">
24628      <div class="toolbar-row" style="margin-bottom:16px;">
24629        <div>
24630          <h2>Visualizations</h2>
24631          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24632        </div>
24633      </div>
24634
24635      <div class="r-viz-grid">
24636        <div class="r-viz-card">
24637          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24638            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24639            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24640          </div>
24641          <div class="r-chart-tab-bar">
24642            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24643            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24644          </div>
24645          <div class="r-chart-container" id="r-composition-chart"></div>
24646        </div>
24647        <div class="r-viz-card">
24648          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24649            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24650            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24651          </div>
24652          <div class="r-chart-container" id="r-scatter-chart"></div>
24653        </div>
24654        {% if has_semantic_data %}
24655        <div class="r-viz-card">
24656          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24657            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24658            <select class="r-chart-select" id="r-semantic-metric">
24659              <option value="functions">Functions</option>
24660              <option value="classes">Classes</option>
24661              <option value="variables">Variables</option>
24662              <option value="imports">Imports</option>
24663              <option value="tests">Tests</option>
24664            </select>
24665            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24666          </div>
24667          <div class="r-chart-container" id="r-semantic-chart"></div>
24668        </div>
24669        {% endif %}
24670        <div class="r-viz-card">
24671          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24672            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24673            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24674          </div>
24675          <div class="r-chart-container" id="r-density-chart"></div>
24676        </div>
24677        <div class="r-viz-card">
24678          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24679            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24680            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24681          </div>
24682          <div class="r-chart-container" id="r-avglines-chart"></div>
24683        </div>
24684        <div class="r-viz-card">
24685          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24686            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24687            <select class="r-chart-select" id="r-sub-metric">
24688              <option value="code">Code Lines</option>
24689              <option value="comment">Comments</option>
24690              <option value="blank">Blank Lines</option>
24691              <option value="physical">Physical Lines</option>
24692              <option value="files">Files</option>
24693            </select>
24694            <select class="r-chart-select" id="r-sub-sort">
24695              <option value="desc">Value ↓</option>
24696              <option value="asc">Value ↑</option>
24697              <option value="name">Name A→Z</option>
24698            </select>
24699            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24700          </div>
24701          <div class="r-chart-container" id="r-submodule-chart"></div>
24702        </div>
24703      </div>
24704
24705    </section>
24706    </div>
24707
24708  </div>
24709
24710  <div id="r-tt" aria-hidden="true"></div>
24711
24712  <script nonce="{{ csp_nonce }}">
24713    (function () {
24714      var body = document.body;
24715      var themeToggle = document.getElementById('theme-toggle');
24716      var storageKey = 'oxide-sloc-theme';
24717
24718      function applyTheme(theme) {
24719        body.classList.toggle('dark-theme', theme === 'dark');
24720      }
24721
24722      function loadSavedTheme() {
24723        try {
24724          var saved = localStorage.getItem(storageKey);
24725          if (saved === 'dark' || saved === 'light') {
24726            applyTheme(saved);
24727          }
24728        } catch (e) {}
24729      }
24730
24731      if (themeToggle) {
24732        themeToggle.addEventListener('click', function () {
24733          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24734          applyTheme(nextTheme);
24735          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24736        });
24737      }
24738
24739      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24740        button.addEventListener('click', function () {
24741          var value = button.getAttribute('data-copy-value') || '';
24742          if (!value) return;
24743          var originalText = button.textContent;
24744          function flashSuccess() {
24745            button.textContent = 'Copied!';
24746            setTimeout(function () { button.textContent = originalText; }, 1800);
24747          }
24748          function flashFail() {
24749            button.textContent = 'Copy failed';
24750            setTimeout(function () { button.textContent = originalText; }, 2000);
24751          }
24752          if (navigator.clipboard && navigator.clipboard.writeText) {
24753            navigator.clipboard.writeText(value).then(flashSuccess, function () {
24754              fallbackCopy(value, flashSuccess, flashFail);
24755            });
24756          } else {
24757            fallbackCopy(value, flashSuccess, flashFail);
24758          }
24759        });
24760      });
24761      function fallbackCopy(text, onSuccess, onFail) {
24762        try {
24763          var ta = document.createElement('textarea');
24764          ta.value = text;
24765          ta.style.position = 'fixed';
24766          ta.style.top = '-9999px';
24767          ta.style.left = '-9999px';
24768          document.body.appendChild(ta);
24769          ta.focus();
24770          ta.select();
24771          var ok = document.execCommand('copy');
24772          document.body.removeChild(ta);
24773          if (ok) { onSuccess(); } else { onFail(); }
24774        } catch (e) { onFail(); }
24775      }
24776
24777      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24778        btn.addEventListener('click', function () {
24779          var folder = btn.getAttribute('data-folder') || '';
24780          if (!folder) return;
24781          var orig = btn.textContent;
24782          fetch('/open-path?path=' + encodeURIComponent(folder))
24783            .then(function (r) { return r.json(); })
24784            .then(function (d) {
24785              if (d && d.server_mode_disabled) {
24786                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24787              } else if (d && d.ok) {
24788                btn.textContent = 'Opened!';
24789                setTimeout(function () { btn.textContent = orig; }, 1800);
24790              }
24791            })
24792            .catch(function () {
24793              btn.textContent = 'Failed';
24794              setTimeout(function () { btn.textContent = orig; }, 2000);
24795            });
24796        });
24797      });
24798
24799      loadSavedTheme();
24800
24801      // ── Compact number formatting for stat chips ──────────────────────────
24802      (function(){
24803        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();}
24804        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24805          var raw=parseInt(chip.getAttribute('data-raw'),10);
24806          if(isNaN(raw))return;
24807          var valEl=chip.querySelector('.stat-chip-val');
24808          if(valEl)valEl.textContent=fmt(raw);
24809          var exactEl=chip.querySelector('.stat-chip-exact');
24810          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24811        });
24812        // Code density chip
24813        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24814          var code=parseInt(chip.getAttribute('data-code'),10);
24815          var phys=parseInt(chip.getAttribute('data-physical'),10);
24816          if(isNaN(code)||isNaN(phys)||phys===0)return;
24817          var pct=(code/phys*100).toFixed(1)+'%';
24818          var valEl=chip.querySelector('.stat-chip-val');
24819          if(valEl)valEl.textContent=pct;
24820        });
24821        // Populate author handle from data-author attribute
24822        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24823          var author=chip.getAttribute('data-author');
24824          var el=chip.querySelector('.author-handle');
24825          if(el)el.textContent='/'+author.replace(/\s+/g,'');
24826        });
24827        // Click-to-copy on run-id-chip elements
24828        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24829          chip.addEventListener('click',function(){
24830            var val=chip.getAttribute('data-copy');
24831            if(!val)return;
24832            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24833            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);}
24834            chip.classList.add('chip-copied-flash');
24835            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24836          });
24837        });
24838        // Format delta card values with data-raw using comma-separated full numbers
24839        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24840          var raw=parseInt(card.getAttribute('data-raw'),10);
24841          if(isNaN(raw))return;
24842          var valEl=card.querySelector('.delta-card-val');
24843          if(valEl)valEl.textContent=raw.toLocaleString();
24844        });
24845        // Format code-before / code-now numbers in the compare banner stats line
24846        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24847          var raw=parseInt(el.getAttribute('data-raw'),10);
24848          if(!isNaN(raw))el.textContent=raw.toLocaleString();
24849        });
24850      })();
24851
24852      // ── Shared tooltip for all result-page charts ─────────────────────────
24853      var rTT=(function(){
24854        var el=document.getElementById('r-tt');
24855        if(!el)return{s:function(){},h:function(){},m:function(){}};
24856        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24857        function hide(){el.style.display='none';}
24858        function move(e){
24859          var x=e.clientX+16,y=e.clientY-12;
24860          var r=el.getBoundingClientRect();
24861          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24862          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24863          el.style.left=x+'px';el.style.top=y+'px';
24864        }
24865        return{s:show,h:hide,m:move};
24866      })();
24867      window.rTT=rTT;
24868
24869      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24870      (function(){
24871        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24872        document.addEventListener('mouseover',function(e){
24873          var t=e.target;
24874          while(t&&t.getAttribute){
24875            var l=t.getAttribute('data-ttl');
24876            if(l!==null){
24877              var v=t.getAttribute('data-ttv')||'';
24878              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24879              return;
24880            }
24881            t=t.parentNode;
24882          }
24883        });
24884        document.addEventListener('mouseout',function(e){
24885          var t=e.target;
24886          while(t&&t.getAttribute){
24887            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24888            t=t.parentNode;
24889          }
24890        });
24891        document.addEventListener('mousemove',function(e){
24892          var el=document.getElementById('r-tt');
24893          if(el&&el.style.display!=='none')rTT.m(e);
24894        });
24895        window.addEventListener('blur',function(){rTT.h();});
24896        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24897      })();
24898
24899      // ── Language overview charts ───────────────────────────────────────────
24900      (function(){
24901        var D={{ lang_chart_json|safe }};
24902        if(!D||!D.length)return;
24903        var el=document.getElementById('result-lang-charts');
24904        if(!el)return;
24905        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24906        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24907        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24908        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();}
24909        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24910        function px(n){return Math.round(n);}
24911        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+'"';}
24912        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24913        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24914        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24915        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;}
24916        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24917
24918        // Donut chart — height matches the stacked-bar chart so both panels align
24919        var rHb_d=28;
24920        var DH=Math.max(220,D.length*rHb_d+32);
24921        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24922        var legX=208,DW=395;
24923        var legCount=D.length;
24924        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24925        var legYStart=Math.round((DH-legCount*legSpacing)/2);
24926        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">';
24927        // One shared transition on every donut element so slices, leader lines,
24928        // outside labels, % labels and the legend all animate together as a single
24929        // picture when a language is hovered. Slices scale from the donut centre.
24930        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>';
24931        if(D.length===1){
24932          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24933          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+'"/>';
24934        } else {
24935          var smalls=[];
24936          var ang=-Math.PI/2;
24937          D.forEach(function(d,i){
24938            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24939            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24940            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24941            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24942            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24943            var pct=Math.round(d.code/tot*100);
24944            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"/>';
24945            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]});}
24946            ang+=sw;
24947          });
24948          // Small slices (<5%) get outside labels positioned near each slice's own
24949          // angular position (a slice on the left gets its label/leader on the left),
24950          // then nudged apart horizontally so text never overlaps. Leader lines point
24951          // from each slice to its label. Horizontal text keeps long names legible;
24952          // the whole SVG scales up in Full View so these stay readable there too.
24953          if(smalls.length){
24954            smalls.sort(function(a,b){return a.mAng-b.mAng;});
24955            var sPad=6,sRowY=11;
24956            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)));});
24957            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;}
24958            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24959            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24960            smalls.forEach(function(sm){
24961              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24962              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;"/>';
24963              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>';
24964            });
24965          }
24966        }
24967        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24968        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24969        D.forEach(function(d,i){
24970          var ly=legYStart+i*legSpacing;
24971          var pctL=Math.round(d.code/tot*100);
24972          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24973          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24974          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24975          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24976          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24977          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24978          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>';
24979          ds+='</g>';
24980        });
24981        ds+='</svg>';
24982
24983        // Horizontal stacked-bar chart — fills container width
24984        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24985        var LW=108,BW=260,svgW=LW+BW+68;
24986        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24987        var barBH=Math.min(32,Math.round(barRhb*0.7));
24988        var SH=DH;
24989        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24990        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">';
24991        D.forEach(function(d,i){
24992          var y=barTopPad+i*barRhb,x=LW;
24993          var phys=d.physical||d.code+d.comments+d.blanks;
24994          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24995          var lmid=y+barBH/2+4;
24996          // Combined breakdown shown when hovering the row, the language name, or the
24997          // total at the bar end (\n becomes a line break in the tooltip).
24998          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24999          bs+='<g class="lang-bar-row">';
25000          // Hit area ends just past the total label so empty space to the right of the
25001          // bar does not trigger the tooltip — only the name, bar and total are hot.
25002          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
25003          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
25004          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>';
25005          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;}
25006          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;}
25007          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>';}
25008          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>';
25009          bs+='</g>';
25010        });
25011        var ly=SH-14;
25012        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
25013        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
25014        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
25015        var totAll=totC+totCm+totBl||1;
25016        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
25017        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
25018        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
25019        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
25020        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
25021        bs+='<g data-kind="code" style="cursor:pointer;">'
25022          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
25023          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
25024          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
25025          +'</g>';
25026        bs+='<g data-kind="comment" style="cursor:pointer;">'
25027          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
25028          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
25029          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
25030          +'</g>';
25031        bs+='<g data-kind="blank" style="cursor:pointer;">'
25032          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
25033          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
25034          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
25035          +'</g>';
25036        bs+='</svg>';
25037        el.innerHTML='<div class="r-lang-overview">'+
25038          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
25039          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
25040        '</div>';
25041        function wireDonutLegend(svg){
25042          if(!svg)return;
25043          // Every donut element carries data-lang: slices (path/circle), leader lines,
25044          // outside labels + % labels (text) and legend rows (g). Hovering any one of
25045          // them emphasises that language across all of them and fades the rest, so the
25046          // slice, its leader line, its label and its legend row move as one picture.
25047          var items=svg.querySelectorAll('[data-lang]');
25048          function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
25049            var tag=el.tagName.toLowerCase();
25050            if(tag==='path'||tag==='circle'){
25051              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)';}
25052              else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
25053              else{el.style.opacity='';el.style.filter='';el.style.transform='';}
25054            }else if(tag==='line'){
25055              if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
25056              else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
25057              else{el.style.opacity='';el.style.strokeWidth='';}
25058            }else if(tag==='text'){
25059              if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
25060              else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
25061              else{el.style.opacity='';el.style.fontWeight='';}
25062            }else{ // legend group
25063              if(st===1){el.style.opacity='1';}
25064              else if(st===-1){el.style.opacity='0.4';}
25065              else{el.style.opacity='';}
25066            }
25067          }
25068          function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
25069          function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
25070          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();});
25071          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();});
25072          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
25073        }
25074        function wireMixLegend(svg){
25075          if(!svg)return;
25076          var legGs=svg.querySelectorAll('g[data-kind]');
25077          var allRects=svg.querySelectorAll('rect[data-kind]');
25078          if(!legGs.length)return;
25079          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';}}
25080          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='';}}
25081          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]);}
25082        }
25083        wireDonutLegend(el.querySelector('svg'));
25084        wireMixLegend(el.querySelectorAll('svg')[1]);
25085
25086        // ── Language breakdown Full View expand ─────────────────────────────────
25087        var langOvBtn=document.getElementById('result-lang-overview-expand');
25088        if(langOvBtn){langOvBtn.addEventListener('click',function(){
25089          var src=document.getElementById('result-lang-charts');
25090          if(!src)return;
25091          var overlay=document.createElement('div');
25092          overlay.className='r-chart-modal-overlay';
25093          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>';
25094          document.body.appendChild(overlay);
25095          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25096          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25097          var wrap=document.getElementById('result-lang-overview-modal-wrap');
25098          if(wrap){
25099            wrap.innerHTML=src.innerHTML;
25100            var svgs=wrap.querySelectorAll('svg');
25101            for(var i=0;i<svgs.length;i++){
25102              svgs[i].removeAttribute('width');
25103              svgs[i].removeAttribute('height');
25104              svgs[i].style.cssText='display:block;width:100%;height:auto;';
25105            }
25106            var ov=wrap.querySelector('.r-lang-overview');
25107            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
25108            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
25109            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
25110            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
25111            wireDonutLegend(wrap.querySelector('svg'));
25112            wireMixLegend(wrap.querySelectorAll('svg')[1]);
25113            requestAnimationFrame(function(){
25114              var ss=wrap.querySelectorAll('svg');
25115              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%;';}}
25116            });
25117          }
25118        });}
25119      })();
25120
25121      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
25122      (function(){
25123        var LANG_D={{ lang_chart_json|safe }};
25124        var SCAT_D={{ scatter_chart_json|safe }};
25125        var SEM_D={{ semantic_chart_json|safe }};
25126        var SUB_D={{ submodule_chart_json|safe }};
25127        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
25128        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
25129        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();}
25130        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
25131        function px(n){return Math.round(n);}
25132        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+'"';}
25133        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
25134        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
25135        // rather than disappear; the SVG scales up in Full View).
25136        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;}
25137
25138        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
25139        function renderCompositionInEl(el,mode,shOvr){
25140          if(!el||!LANG_D||!LANG_D.length)return;
25141          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
25142          var LW=110,SH=shOvr||300;
25143          var svgW=Math.max(320,el.offsetWidth||480);
25144          var BW=Math.max(120,svgW-LW-80);
25145          var legendH=24,topPad=4;
25146          var n=LANG_D.length||1;
25147          var rowTotal=Math.floor((SH-legendH-topPad)/n);
25148          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25149          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">';
25150          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
25151          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
25152          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
25153          var totAll2=totC2+totCm2+totBl2||1;
25154          if(mode==='pct'){
25155            LANG_D.forEach(function(d,i){
25156              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
25157              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
25158              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25159              var lmid=y+Math.floor(bH/2)+4;
25160              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
25161              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>';
25162              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;}
25163              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;}
25164              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>';}
25165              var pct=Math.round((d.code||0)/tot2*100);
25166              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>';
25167            });
25168          } else {
25169            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
25170            LANG_D.forEach(function(d,i){
25171              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
25172              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
25173              var lmid=y+Math.floor(bH/2)+4;
25174              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));
25175              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>';
25176              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;}
25177              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;}
25178              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>';}
25179              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>';
25180            });
25181          }
25182          var ly=SH-legendH+4;
25183          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
25184          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
25185          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
25186          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
25187          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
25188          s+='<g data-kind="code" style="cursor:pointer;">'
25189            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
25190            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
25191            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
25192            +'</g>';
25193          s+='<g data-kind="comment" style="cursor:pointer;">'
25194            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
25195            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
25196            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
25197            +'</g>';
25198          s+='<g data-kind="blank" style="cursor:pointer;">'
25199            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
25200            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
25201            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
25202            +'</g>';
25203          s+='</svg>';
25204          el.innerHTML=s;
25205          wireMixLegendEl(el);
25206        }
25207        function wireMixLegendEl(container){
25208          var svg=container&&container.querySelector('svg');
25209          if(!svg)return;
25210          var legGs=svg.querySelectorAll('g[data-kind]');
25211          var allRects=svg.querySelectorAll('rect[data-kind]');
25212          if(!legGs.length)return;
25213          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';}}
25214          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='';}}
25215          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]);}
25216        }
25217        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
25218        renderComposition('abs');
25219        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
25220          btn.addEventListener('click',function(){
25221            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
25222            btn.classList.add('active');
25223            renderComposition(btn.getAttribute('data-rcomp'));
25224          });
25225        });
25226
25227        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
25228        function wireScatterLegend(container){
25229          var svg=container&&container.querySelector('svg');
25230          if(!svg)return;
25231          var legGs=svg.querySelectorAll('g[data-lang]');
25232          var circs=svg.querySelectorAll('circle[data-lang]');
25233          var labs=svg.querySelectorAll('text[data-lang]');
25234          if(!legGs.length)return;
25235          // Raise an element to the top of its parent so the hovered bubble and its
25236          // name/number labels sit above overlapping neighbours (clustered bubbles
25237          // otherwise bury the one you are trying to read).
25238          function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
25239          function hl(lang){
25240            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';}}
25241            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';}}
25242            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
25243          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='';}}
25244          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]);}
25245        }
25246        function renderScatterInEl(el,hOvr){
25247          if(!el||!SCAT_D||!SCAT_D.length)return;
25248          var n=SCAT_D.length;
25249          var H=hOvr||300,PL=52,PB=36,PT=44;
25250          var W=Math.max(320,el.offsetWidth||480);
25251          var cH=H-PT-PB;
25252          // Legend: max 2 columns, fills vertical space. The compact card shows the
25253          // top languages by code lines plus a "+N more" row linking to Full View;
25254          // Full View (hOvr set) shows every language across up to 2 tall columns.
25255          var compact=!hOvr;
25256          var availH=Math.max(120,H-24);
25257          var rowsFit=Math.max(2,Math.floor(availH/18));
25258          var legTrunc=compact&&(n>2*rowsFit);
25259          var legShown=legTrunc?(2*rowsFit-1):n;
25260          var legTotal=legTrunc?(2*rowsFit):n;
25261          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
25262          var legPerCol=Math.ceil(legTotal/legCols);
25263          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
25264          var legColW=hOvr?144:130;
25265          var LG=26;
25266          var legW=legCols*legColW;
25267          var cW=W-PL-LG-legW;
25268          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
25269          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
25270          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
25271          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
25272          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
25273          var logMaxF=Math.log1p(maxF);
25274          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">';
25275          // Smooth the legend-hover fade so bubbles + labels animate together.
25276          s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
25277          // Y grid lines (linear)
25278          [0,0.25,0.5,0.75,1].forEach(function(t){
25279            var y=PT+cH*(1-t);
25280            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25281            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>';
25282          });
25283          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
25284          [0,0.25,0.5,0.75,1].forEach(function(t){
25285            var x=PL+cW*t;
25286            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
25287            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
25288            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>';
25289          });
25290          // Full View (hOvr set) has the vertical room to show the per-bubble value
25291          // line; the compact card shows only the language label to avoid the
25292          // overlapping-label clutter seen when bubbles cluster together.
25293          var showVal=!!hOvr;
25294          SCAT_D.forEach(function(d,i){
25295            // X uses log1p so outlier languages (many files) don't push others to the far left
25296            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
25297            var cy2=PT+cH-d.code/maxC*cH;
25298            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
25299            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"/>';
25300            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
25301            if(showVal){
25302              var ty2=Math.max(24,px(cy2)-px(r)-3);
25303              var ty1=Math.max(12,ty2-14);
25304              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>';
25305              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>';
25306            }else{
25307              var ly2=Math.max(12,px(cy2)-px(r)-3);
25308              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>';
25309            }
25310          });
25311          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>';
25312          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>';
25313          // Legend (right side — top languages, max 2 columns, fills height)
25314          var legX=PL+cW+LG;
25315          var legBlockH=legPerCol*legRowH;
25316          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
25317          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
25318          for(var lk=0;lk<legShown;lk++){
25319            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
25320            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
25321            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;">';
25322            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25323            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
25324            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>';
25325            s+='</g>';
25326          }
25327          if(legTrunc){
25328            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25329            s+='<g data-more="1" style="cursor:pointer;">';
25330            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25331            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25332            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>';
25333            s+='</g>';
25334          }
25335          s+='</svg>';
25336          el.innerHTML=s;
25337          wireScatterLegend(el);
25338          var moreEl=el.querySelector('g[data-more]');
25339          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25340        }
25341        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25342
25343        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25344        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25345        // the old vertical column layout on wide containers.
25346        function renderSemanticInEl(el,key,sh){
25347          if(!el||!SEM_D||!SEM_D.length)return;
25348          var n2=SEM_D.length||1;
25349          var LW=112,SH=sh||Math.max(180,n2*28+26);
25350          var svgW=Math.max(320,el.offsetWidth||480);
25351          var BW=Math.max(120,svgW-LW-80);
25352          var topPad=4,botPad=14;
25353          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25354          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25355          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25356          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">';
25357          SEM_D.forEach(function(d,i){
25358            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25359            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>';
25360            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"/>';
25361            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>';
25362          });
25363          s+='</svg>';
25364          el.innerHTML=s;
25365        }
25366        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25367        var semSel=document.getElementById('r-semantic-metric');
25368        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25369        var semExpand=document.getElementById('r-semantic-expand');
25370        if(semExpand){
25371          semExpand.addEventListener('click',function(){
25372            var key=semSel?semSel.value:'functions';
25373            var n=SEM_D.length||1;
25374            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25375            var modalH=Math.min(Math.max(360,n*38+60),maxH);
25376            var overlay=document.createElement('div');
25377            overlay.className='r-chart-modal-overlay';
25378            var optHtml=
25379              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25380              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25381              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25382              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25383              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25384            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>';
25385            document.body.appendChild(overlay);
25386            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25387            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25388            var modalEl=document.getElementById('r-sem-modal-chart');
25389            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25390            var modalSel=document.getElementById('r-sem-modal-metric');
25391            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25392          });
25393        }
25394
25395        // ── Expand buttons: re-render charts at large size inside modal ──────────
25396        (function(){
25397          function makeExpandModal(title,mH,subtitle,ctrlHtml){
25398            var overlay=document.createElement('div');
25399            overlay.className='r-chart-modal-overlay';
25400            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25401            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25402            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>';
25403            document.body.appendChild(overlay);
25404            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25405            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25406            return overlay.querySelector('.r-expand-modal-chart');
25407          }
25408          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25409          var compExpandBtn=document.getElementById('r-composition-expand');
25410          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25411            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25412            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25413            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25414              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25415            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25416            if(wrap){
25417              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25418              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25419                btn.addEventListener('click',function(){
25420                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25421                  btn.classList.add('active');
25422                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25423                });
25424              });
25425            }
25426          });}
25427          var scatExpandBtn=document.getElementById('r-scatter-expand');
25428          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25429            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25430            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25431          });}
25432          var densExpandBtn=document.getElementById('r-density-expand');
25433          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25434            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25435            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25436            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25437          });}
25438          var avgExpandBtn=document.getElementById('r-avglines-expand');
25439          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25440            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25441            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25442            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25443          });}
25444          var subExpandBtn=document.getElementById('r-submodule-expand');
25445          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25446            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25447            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25448            var metCtrl=
25449              '<select class="r-chart-select" id="r-sub-modal-metric">'
25450              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25451              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25452              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25453              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25454              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25455              +'</select>';
25456            var sortCtrl=
25457              '<select class="r-chart-select" id="r-sub-modal-sort">'
25458              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25459              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25460              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25461              +'</select>';
25462            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25463            if(wrap){
25464              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25465              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25466              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25467              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25468              if(mSub)mSub.addEventListener('change',reRenderSub);
25469              if(mSort)mSort.addEventListener('change',reRenderSub);
25470            }
25471          });}
25472        })();
25473
25474        // ── Comment Density: comments / (code + comments) per language ───────────
25475        function renderDensityInEl(el,shOvr){
25476          if(!el||!LANG_D||!LANG_D.length)return;
25477          var n=LANG_D.length||1;
25478          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25479          var svgW=Math.max(320,el.offsetWidth||480);
25480          var BW=Math.max(120,svgW-LW-80);
25481          var topPad=4,botPad=26;
25482          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25483          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25484          var densities=LANG_D.map(function(d){
25485            var sig=(d.code||0)+(d.comments||0);
25486            return sig>0?(d.comments||0)/sig:0;
25487          });
25488          var maxDen=Math.max.apply(null,densities)||1;
25489          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">';
25490          LANG_D.forEach(function(d,i){
25491            var den=densities[i],bw=den/maxDen*BW;
25492            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25493            var pct=Math.round(den*100);
25494            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>';
25495            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"/>';
25496            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25497            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>';
25498          });
25499          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>';
25500          s+='</svg>';
25501          el.innerHTML=s;
25502        }
25503        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25504        renderDensity();
25505
25506        // ── Avg Lines per File: code / files per language ─────────────────────
25507        function renderAvgLinesInEl(el,shOvr){
25508          if(!el||!LANG_D||!LANG_D.length)return;
25509          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25510          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25511          var n=data.length||1;
25512          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25513          var svgW=Math.max(320,el.offsetWidth||480);
25514          var BW=Math.max(120,svgW-LW-80);
25515          var topPad=4,botPad=26;
25516          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25517          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25518          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25519          var maxAvg=Math.max.apply(null,avgs)||1;
25520          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">';
25521          data.forEach(function(d,i){
25522            var avg=avgs[i],bw=avg/maxAvg*BW;
25523            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25524            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>';
25525            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"/>';
25526            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25527            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>';
25528          });
25529          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>';
25530          s+='</svg>';
25531          el.innerHTML=s;
25532        }
25533        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25534        renderAvgLines();
25535
25536        // ── Repository Overview: overall row + per-submodule rows ────────────
25537        function renderSubmoduleInEl(el,key,sort,shOvr){
25538          if(!el)return;
25539          var overall={
25540            name:'Overall',
25541            code:{{ code_lines }},
25542            comment:{{ comment_lines }},
25543            blank:{{ blank_lines }},
25544            physical:{{ physical_lines }},
25545            files:{{ files_analyzed }},
25546            isOverall:true
25547          };
25548          var subs=SUB_D.slice();
25549          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25550          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25551          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25552          var data=[overall].concat(subs);
25553          var sepH=subs.length>0?14:0;
25554          var naturalH=data.length*32+sepH+16;
25555          var SH=shOvr||Math.max(100,naturalH);
25556          var svgW=Math.max(320,el.offsetWidth||480);
25557          var LW=116,BW=Math.max(200,svgW-LW-54);
25558          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25559          var OVERALL_COL='#6b7280';
25560          var topPad=4,botPad=8;
25561          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25562          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25563          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">';
25564          var yOff=topPad;
25565          data.forEach(function(d,i){
25566            var v=d[key]||0,bw=v/maxV*BW;
25567            var y=yOff+Math.floor((rowSlot-bH)/2);
25568            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25569            var label=d.name||d.path||'?';
25570            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>';
25571            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25572            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25573            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>';
25574            yOff+=rowSlot;
25575            if(d.isOverall&&subs.length>0){
25576              yOff+=sepH;
25577            }
25578          });
25579          s+='</svg>';
25580          el.innerHTML=s;
25581        }
25582        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25583        var subSel=document.getElementById('r-sub-metric');
25584        var sortSel=document.getElementById('r-sub-sort');
25585        renderSubmodule('code','desc');
25586        if(subSel){
25587          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25588          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25589        }
25590
25591        // Equalise heights within each chart row: if one chart in a grid row is taller
25592        // than its neighbour, re-render the shorter one at the taller height so bars fill
25593        // the available vertical space instead of leaving a gap.
25594        function syncRowHeights(){
25595          var avgEl=document.getElementById('r-avglines-chart');
25596          var subEl=document.getElementById('r-submodule-chart');
25597          if(avgEl&&subEl){
25598            var avgSvg=avgEl.querySelector('svg');
25599            var subSvg=subEl.querySelector('svg');
25600            if(avgSvg&&subSvg){
25601              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25602              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25603              var key=subSel?subSel.value||'code':'code';
25604              var sort=sortSel?sortSel.value:'desc';
25605              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25606              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25607            }
25608          }
25609          var semEl=document.getElementById('r-semantic-chart');
25610          var denEl=document.getElementById('r-density-chart');
25611          if(semEl&&denEl){
25612            var semSvg=semEl.querySelector('svg');
25613            var denSvg=denEl.querySelector('svg');
25614            if(semSvg&&denSvg){
25615              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25616              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25617              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25618              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25619            }
25620          }
25621        }
25622        syncRowHeights();
25623
25624        // Re-render all SVG charts when the window is resized so bars fill the card.
25625        var _rResizeTimer;
25626        window.addEventListener('resize',function(){
25627          clearTimeout(_rResizeTimer);
25628          _rResizeTimer=setTimeout(function(){
25629            var rcompBtn=document.querySelector('[data-rcomp].active');
25630            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25631            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25632            if(semSel)renderSemantic(semSel.value||'functions');
25633            renderDensity();
25634            renderAvgLines();
25635            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25636            syncRowHeights();
25637          },120);
25638        });
25639      })();
25640
25641      (function randomizeWatermarks() {
25642        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25643        if (!wms.length) return;
25644        var placed = [];
25645        function tooClose(top, left) {
25646          for (var i = 0; i < placed.length; i++) {
25647            var dt = Math.abs(placed[i][0] - top);
25648            var dl = Math.abs(placed[i][1] - left);
25649            if (dt < 20 && dl < 18) return true;
25650          }
25651          return false;
25652        }
25653        function pick(leftBand) {
25654          for (var attempt = 0; attempt < 50; attempt++) {
25655            var top = Math.random() * 85 + 5;
25656            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25657            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25658          }
25659          var top = Math.random() * 85 + 5;
25660          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25661          placed.push([top, left]);
25662          return [top, left];
25663        }
25664        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25665        var half = Math.floor(wms.length / 2);
25666        wms.forEach(function (img, i) {
25667          var pos = pick(i < half);
25668          var size = Math.floor(Math.random() * 100 + 160);
25669          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25670          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25671          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;
25672        });
25673      })();
25674
25675      (function spawnCodeParticles() {
25676        var container = document.getElementById('code-particles');
25677        if (!container) return;
25678        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'];
25679        for (var i = 0; i < 38; i++) {
25680          (function(idx) {
25681            var el = document.createElement('span');
25682            el.className = 'code-particle';
25683            el.textContent = snippets[idx % snippets.length];
25684            var left = Math.random() * 94 + 2;
25685            var top = Math.random() * 88 + 6;
25686            var dur = (Math.random() * 10 + 9).toFixed(1);
25687            var delay = (Math.random() * 18).toFixed(1);
25688            var rot = (Math.random() * 26 - 13).toFixed(1);
25689            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25690            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';
25691            container.appendChild(el);
25692          })(i);
25693        }
25694      })();
25695
25696      {% if pdf_generating %}
25697      // Poll for PDF readiness and swap the disabled button to a live link once done.
25698      (function() {
25699        var openBtn = document.getElementById('pdf-open-btn');
25700        var dlBtn = document.getElementById('pdf-download-btn');
25701        function checkPdf() {
25702          fetch('/api/runs/{{ run_id }}/pdf-status')
25703            .then(function(r) { return r.json(); })
25704            .then(function(d) {
25705              if (d.ready) {
25706                if (openBtn) {
25707                  var a = document.createElement('a');
25708                  a.className = 'button';
25709                  a.id = 'pdf-open-btn';
25710                  a.href = '/runs/pdf/{{ run_id }}';
25711                  a.target = '_blank';
25712                  a.rel = 'noopener';
25713                  a.textContent = 'Open PDF';
25714                  openBtn.replaceWith(a);
25715                }
25716                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25717              } else {
25718                setTimeout(checkPdf, 3000);
25719              }
25720            })
25721            .catch(function() { setTimeout(checkPdf, 5000); });
25722        }
25723        setTimeout(checkPdf, 3000);
25724      })();
25725      {% endif %}
25726
25727    })();
25728  </script>
25729  <script nonce="{{ csp_nonce }}">
25730  (function(){
25731    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'}];
25732    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);});}
25733    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25734    function init(){
25735      var btn=document.getElementById('settings-btn');if(!btn)return;
25736      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25737      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>';
25738      document.body.appendChild(m);
25739      var g=document.getElementById('scheme-grid');
25740      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);});
25741      var cl=document.getElementById('settings-close');
25742      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);});})();
25743      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');});
25744      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25745      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25746    }
25747    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25748  }());
25749  </script>
25750  <footer class="site-footer">
25751    local code analysis - metrics, history and reports
25752    &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>
25753    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25754    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25755    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25756    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25757  </footer>
25758  {% if confluence_configured %}
25759  <script nonce="{{ csp_nonce }}">
25760  (function() {
25761    var postBtn = document.getElementById('postConfluenceBtn');
25762    var copyBtn = document.getElementById('copyWikiBtn');
25763    var modal   = document.getElementById('confluenceModal');
25764    if (!postBtn || !modal) return;
25765
25766    postBtn.addEventListener('click', function() {
25767      document.getElementById('confStatus').style.display = 'none';
25768      modal.style.display = 'flex';
25769    });
25770    document.getElementById('confCancelBtn').addEventListener('click', function() {
25771      modal.style.display = 'none';
25772    });
25773    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25774
25775    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25776      var btn = this;
25777      btn.disabled = true;
25778      var status = document.getElementById('confStatus');
25779      status.style.display = 'block';
25780      status.style.background = '#dbeafe';
25781      status.style.color = '#1e40af';
25782      status.textContent = 'Posting to Confluence\u2026';
25783      var resp = await fetch('/api/confluence/post', {
25784        method: 'POST',
25785        headers: { 'Content-Type': 'application/json' },
25786        body: JSON.stringify({
25787          run_id: '{{ run_id }}',
25788          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25789          report_url: document.getElementById('confReportUrl').value.trim() || null
25790        })
25791      });
25792      var data = await resp.json();
25793      if (data.ok) {
25794        status.style.background = '#dcfce7'; status.style.color = '#166534';
25795        status.textContent = 'Posted! Page ID: ' + data.page_id;
25796      } else {
25797        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25798        status.textContent = 'Error: ' + (data.error || 'Unknown error');
25799      }
25800      btn.disabled = false;
25801    });
25802
25803    if (copyBtn) {
25804      copyBtn.addEventListener('click', async function() {
25805        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25806        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25807        var text = await resp.text();
25808        try {
25809          await navigator.clipboard.writeText(text);
25810          var orig = copyBtn.textContent;
25811          copyBtn.textContent = 'Copied!';
25812          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25813        } catch(e) {
25814          alert('Clipboard write failed \u2014 check browser permissions.');
25815        }
25816      });
25817    }
25818  })();
25819  </script>
25820  {% endif %}
25821  <script nonce="{{ csp_nonce }}">
25822  (function() {
25823    var deleteBtn = document.getElementById('delete-run-btn');
25824    var modal     = document.getElementById('delete-run-modal');
25825    var cancelBtn = document.getElementById('delete-run-cancel');
25826    var confirmBtn= document.getElementById('delete-run-confirm');
25827    if (!deleteBtn || !modal) return;
25828    deleteBtn.addEventListener('click', function() {
25829      document.getElementById('delete-run-status').style.display = 'none';
25830      modal.style.display = 'flex';
25831    });
25832    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25833    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25834    confirmBtn.addEventListener('click', async function() {
25835      confirmBtn.disabled = true;
25836      cancelBtn.disabled = true;
25837      var status = document.getElementById('delete-run-status');
25838      status.style.display = 'block';
25839      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25840      status.textContent = 'Deleting\u2026';
25841      try {
25842        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25843        if (resp.status === 204 || resp.ok) {
25844          status.style.background = '#dcfce7'; status.style.color = '#166534';
25845          status.textContent = 'Deleted. Redirecting\u2026';
25846          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25847        } else {
25848          var d = await resp.json().catch(function(){return {};});
25849          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25850          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25851          confirmBtn.disabled = false;
25852          cancelBtn.disabled = false;
25853        }
25854      } catch (e) {
25855        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25856        status.textContent = 'Network error: ' + String(e);
25857        confirmBtn.disabled = false;
25858        cancelBtn.disabled = false;
25859      }
25860    });
25861  })();
25862  </script>
25863  <script nonce="{{ csp_nonce }}">(function(){
25864    var bundleBtn = document.getElementById('download-bundle-btn');
25865    if (bundleBtn) {
25866      bundleBtn.addEventListener('click', function() {
25867        bundleBtn.disabled = true;
25868        var orig = bundleBtn.textContent;
25869        bundleBtn.textContent = 'Preparing\u2026';
25870        fetch('/api/runs/{{ run_id }}/bundle')
25871          .then(function(r) {
25872            if (!r.ok) throw new Error('HTTP ' + r.status);
25873            return r.blob();
25874          })
25875          .then(function(blob) {
25876            var url = URL.createObjectURL(blob);
25877            var a = document.createElement('a');
25878            a.href = url;
25879            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25880            document.body.appendChild(a);
25881            a.click();
25882            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25883            bundleBtn.disabled = false;
25884            bundleBtn.textContent = orig;
25885          })
25886          .catch(function(e) {
25887            bundleBtn.disabled = false;
25888            bundleBtn.textContent = orig;
25889            alert('Bundle download failed: ' + String(e));
25890          });
25891      });
25892    }
25893  })();</script>
25894  <script nonce="{{ csp_nonce }}">(function(){
25895    var dot=document.getElementById('status-dot');
25896    var pingEl=document.getElementById('server-ping-ms');
25897    var tipEl=document.getElementById('server-tip-ping');
25898    var fm=document.getElementById('footer-mode');
25899    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)';}}
25900    function doPing(){
25901      var t0=performance.now();
25902      fetch('/healthz',{cache:'no-store'})
25903        .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);})
25904        .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)';}});
25905    }
25906    doPing();
25907    setInterval(doPing,5000);
25908    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');}
25909  })();</script>
25910  <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>
25911  {% if let Some(banner) = report_header_footer %}
25912  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25913  {% endif %}
25914</body>
25915</html>
25916"##,
25917    ext = "html"
25918)]
25919// Template structs need many bool fields to pass Askama rendering flags.
25920#[allow(clippy::struct_excessive_bools)]
25921struct ResultTemplate {
25922    version: &'static str,
25923    report_title: String,
25924    project_path: String,
25925    output_dir: String,
25926    run_id: String,
25927    files_analyzed: u64,
25928    files_skipped: u64,
25929    physical_lines: u64,
25930    code_lines: u64,
25931    comment_lines: u64,
25932    blank_lines: u64,
25933    mixed_lines: u64,
25934    functions: u64,
25935    classes: u64,
25936    variables: u64,
25937    imports: u64,
25938    html_url: Option<String>,
25939    pdf_url: Option<String>,
25940    json_url: Option<String>,
25941    html_download_url: Option<String>,
25942    pdf_download_url: Option<String>,
25943    json_download_url: Option<String>,
25944    html_path: Option<String>,
25945    json_path: Option<String>,
25946    prev_run_id: Option<String>,
25947    prev_run_timestamp: Option<String>,
25948    prev_run_code_lines: Option<u64>,
25949    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
25950    prev_fa_str: String,
25951    prev_fs_str: String,
25952    prev_pl_str: String,
25953    prev_cl_str: String,
25954    prev_cml_str: String,
25955    prev_bl_str: String,
25956    // Signed change column for main metrics
25957    delta_fa_str: String,
25958    delta_fa_class: String,
25959    delta_fs_str: String,
25960    delta_fs_class: String,
25961    delta_pl_str: String,
25962    delta_pl_class: String,
25963    delta_cl_str: String,
25964    delta_cl_class: String,
25965    delta_cml_str: String,
25966    delta_cml_class: String,
25967    delta_bl_str: String,
25968    delta_bl_class: String,
25969    // delta vs previous scan
25970    delta_lines_added: Option<i64>,
25971    delta_lines_removed: Option<i64>,
25972    delta_lines_net_str: String,
25973    delta_lines_net_class: String,
25974    delta_files_added: Option<usize>,
25975    delta_files_removed: Option<usize>,
25976    delta_files_modified: Option<usize>,
25977    delta_files_unchanged: Option<usize>,
25978    delta_files_total: Option<usize>,
25979    delta_unmodified_lines: Option<u64>,
25980    // git context
25981    git_branch: Option<String>,
25982    git_branch_url: Option<String>,
25983    git_commit: Option<String>,
25984    git_commit_long: Option<String>,
25985    git_author: Option<String>,
25986    git_commit_url: Option<String>,
25987    // scan metadata for hero section
25988    scan_performed_by: String,
25989    scan_time_display: String,
25990    scan_time_utc_ms: i64,
25991    os_display: String,
25992    test_count: u64,
25993    // reserve "pad" card, revealed by JS only when the visible card count is odd
25994    test_assertion_count: u64,
25995    // history
25996    prev_scan_count: usize,
25997    current_scan_number: usize,
25998    // submodule breakdown (empty when not requested)
25999    submodule_rows: Vec<SubmoduleRow>,
26000    scan_config_url: String,
26001    lang_chart_json: String,
26002    // Askama reads these via proc-macro expansion; clippy can't trace through it.
26003    #[allow(dead_code)]
26004    scatter_chart_json: String,
26005    #[allow(dead_code)]
26006    semantic_chart_json: String,
26007    #[allow(dead_code)]
26008    submodule_chart_json: String,
26009    #[allow(dead_code)]
26010    has_submodule_data: bool,
26011    #[allow(dead_code)]
26012    has_semantic_data: bool,
26013    pdf_generating: bool,
26014    csp_nonce: String,
26015    /// Whether Confluence integration is configured — shows Post button when true.
26016    confluence_configured: bool,
26017    server_mode: bool,
26018    /// Header/footer identification banner, mirrored from the HTML/PDF report.
26019    report_header_footer: Option<String>,
26020    run_id_short: String,
26021    /// True when rendering a static offline file (index.html); hides server-only actions.
26022    #[allow(dead_code)]
26023    is_offline: bool,
26024    /// Total cyclomatic complexity score across all analyzed files.
26025    cyclomatic_complexity: u64,
26026    /// Logical SLOC (statement count) when available; None for unsupported languages.
26027    lsloc: Option<u64>,
26028    /// Unique Lines of Code across all analyzed files.
26029    uloc: u64,
26030    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
26031    dryness_pct_str: String,
26032    /// Number of duplicate file groups detected.
26033    duplicate_group_count: usize,
26034    /// Whether a COCOMO estimate is available to display.
26035    has_cocomo: bool,
26036    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
26037    cocomo_effort_str: String,
26038    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
26039    cocomo_duration_str: String,
26040    /// Pre-formatted average team size, e.g. "2.32".
26041    cocomo_staff_str: String,
26042    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
26043    cocomo_ksloc_str: String,
26044    /// COCOMO mode label shown in the card (e.g. "Organic").
26045    cocomo_mode_label: String,
26046    /// Tooltip text explaining the selected COCOMO mode.
26047    cocomo_mode_tooltip: String,
26048    /// Per-file complexity alert threshold. 0 = off (no highlighting).
26049    complexity_alert: u32,
26050    /// Whether any file has coverage data attached.
26051    has_coverage_data: bool,
26052    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
26053    cov_line_pct: String,
26054    /// Overall function coverage percentage string — empty if no data.
26055    cov_fn_pct: String,
26056    /// Overall branch coverage percentage string — empty if no branch data.
26057    cov_branch_pct: String,
26058    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
26059    cov_lines_summary: String,
26060}
26061
26062#[derive(Template)]
26063#[template(
26064    source = r##"
26065<!doctype html>
26066<html lang="en">
26067<head>
26068  <meta charset="utf-8">
26069  <meta name="viewport" content="width=device-width, initial-scale=1">
26070  <title>OxideSLOC | Analyzing…</title>
26071  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26072  <style nonce="{{ csp_nonce }}">
26073    :root {
26074      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26075      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26076      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26077      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26078    }
26079    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26080    *{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;}
26081    .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);}
26082    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26083    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
26084    .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));}
26085    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26086    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
26087    .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    .page-body{padding:32px 24px 36px;}
26095    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
26096    .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;}
26097    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
26098    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
26099    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
26100    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
26101    .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;}
26102    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
26103    .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;}
26104    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
26105    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
26106    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
26107    .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;}
26108    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
26109    .hidden{display:none!important;}
26110    .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;}
26111    .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;}
26112    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
26113    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
26114    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
26115    .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);}
26116    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
26117    .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;}
26118    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
26119    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26120    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26121    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
26122    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26123    .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;}
26124    @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));}}
26125    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26126    .site-footer a{color:var(--muted);}
26127    .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;}
26128    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
26129    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
26130    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
26131  </style>
26132</head>
26133<body>
26134  <div class="background-watermarks" aria-hidden="true">
26135    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26136    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26137    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26138    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26139    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26140    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26141  </div>
26142  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26143  <nav class="top-nav">
26144    <div class="top-nav-inner">
26145      <a href="/" class="brand">
26146        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
26147        <div class="brand-copy">
26148          <h1 class="brand-title">OxideSLOC</h1>
26149          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26150        </div>
26151      </a>
26152      <div class="nav-right">
26153        <a class="nav-pill" href="/">Home</a>
26154        <div class="nav-dropdown">
26155          <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>
26156          <div class="nav-dropdown-menu">
26157            <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>
26158          </div>
26159        </div>
26160        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26161        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26162        <div class="nav-dropdown">
26163          <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>
26164          <div class="nav-dropdown-menu">
26165            <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>
26166          </div>
26167        </div>
26168        <div class="server-status-wrap" id="server-status-wrap">
26169          <div class="nav-pill server-online-pill" id="server-status-pill">
26170            <span class="status-dot" id="status-dot"></span>
26171            <span id="server-status-label">Server</span>
26172            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26173          </div>
26174          <div class="server-status-tip">
26175            OxideSLOC is running — accessible on your network.
26176            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26177          </div>
26178        </div>
26179        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26180          <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>
26181        </button>
26182        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26183          <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>
26184          <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>
26185        </button>
26186      </div>
26187    </div>
26188  </nav>
26189  <div class="page-body">
26190    <div class="wait-panel">
26191      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
26192      <h2 class="wait-title">Analyzing your project…</h2>
26193      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
26194      <div class="path-block">{{ project_path }}</div>
26195      <div class="metrics-row">
26196        <div class="metric-card">
26197          <div class="metric-label">Elapsed</div>
26198          <div class="metric-value" id="elapsed">0s</div>
26199        </div>
26200        <div class="metric-card">
26201          <div class="metric-label">Phase</div>
26202          <div class="metric-value" id="phase">Starting</div>
26203        </div>
26204        <div class="metric-card hidden" id="files-card">
26205          <div class="metric-label">Files</div>
26206          <div class="metric-value" id="files-progress">0</div>
26207        </div>
26208      </div>
26209      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
26210      <div class="warn-slow hidden" id="warn-slow">
26211        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.
26212      </div>
26213      <div class="err-panel hidden" id="err-panel">
26214        <strong>Analysis failed</strong>
26215        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
26216      </div>
26217      <div class="actions hidden" id="actions">
26218        <a href="/scan" class="btn-primary">Try Again</a>
26219        <a href="/view-reports" class="btn-outline">View Reports</a>
26220      </div>
26221    </div>
26222  </div>
26223  <script nonce="{{ csp_nonce }}">
26224    (function() {
26225      var WAIT_ID = {{ wait_id_json|safe }};
26226      var startTime = Date.now();
26227      var pollInterval = 1500;
26228      var retries = 0;
26229      var maxRetries = 5;
26230      var warnShown = false;
26231
26232      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();}
26233
26234      function elapsed() {
26235        return Math.floor((Date.now() - startTime) / 1000);
26236      }
26237
26238      function updateElapsed() {
26239        var s = elapsed();
26240        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
26241      }
26242
26243      function setPhase(txt) {
26244        document.getElementById('phase').textContent = txt;
26245      }
26246
26247      var elapsedTimer = setInterval(updateElapsed, 1000);
26248
26249      function poll() {
26250        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
26251          .then(function(r) {
26252            if (!r.ok) throw new Error('HTTP ' + r.status);
26253            return r.json();
26254          })
26255          .then(function(data) {
26256            retries = 0;
26257            if (data.state === 'complete') {
26258              clearInterval(elapsedTimer);
26259              setPhase('Done');
26260              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
26261            } else if (data.state === 'failed') {
26262              clearInterval(elapsedTimer);
26263              setPhase('Failed');
26264              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
26265              document.getElementById('err-panel').classList.remove('hidden');
26266              document.getElementById('actions').classList.remove('hidden');
26267            } else {
26268              // still running
26269              var s = elapsed();
26270              if (s > 90 && !warnShown) {
26271                warnShown = true;
26272                document.getElementById('warn-slow').classList.remove('hidden');
26273              }
26274              setPhase(data.phase || 'Running');
26275              var fd = data.files_done || 0, ft = data.files_total || 0;
26276              if (ft > 0) {
26277                var card = document.getElementById('files-card');
26278                if (card) card.classList.remove('hidden');
26279                var fp = document.getElementById('files-progress');
26280                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
26281              }
26282              setTimeout(poll, pollInterval);
26283            }
26284          })
26285          .catch(function(err) {
26286            retries++;
26287            if (retries >= maxRetries) {
26288              clearInterval(elapsedTimer);
26289              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
26290              document.getElementById('err-panel').classList.remove('hidden');
26291              document.getElementById('actions').classList.remove('hidden');
26292            } else {
26293              // exponential back-off capped at 8s
26294              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
26295            }
26296          });
26297      }
26298
26299      setTimeout(poll, pollInterval);
26300
26301      // If the browser restores this page from bfcache (Back after viewing results),
26302      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
26303      window.addEventListener("pageshow", function(e) {
26304        if (e.persisted) { setTimeout(poll, 200); }
26305      });
26306    })();
26307  </script>
26308  <footer class="site-footer">
26309    local code analysis - metrics, history and reports
26310    &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>
26311    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26312    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26313    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26314    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26315  </footer>
26316  <script nonce="{{ csp_nonce }}">
26317    (function(){
26318      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26319      if(s==="dark")b.classList.add("dark-theme");
26320      var tt=document.getElementById("theme-toggle");
26321      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
26322    })();
26323    (function spawnCodeParticles(){
26324      var c=document.getElementById('code-particles');if(!c)return;
26325      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'];
26326      for(var i=0;i<32;i++){(function(idx){
26327        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26328        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26329        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26330        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26331        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26332        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26333        c.appendChild(el);
26334      })(i);}
26335    })();
26336    (function randomizeWatermarks(){
26337      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26338      var placed=[];
26339      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;}
26340      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];}
26341      var half=Math.floor(wms.length/2);
26342      wms.forEach(function(img,i){
26343        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26344        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26345        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26346        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26347        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26348        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26349      });
26350    })();
26351  </script>
26352  <script nonce="{{ csp_nonce }}">
26353  (function(){
26354    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'}];
26355    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);});}
26356    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26357    function init(){
26358      var btn=document.getElementById('settings-btn');if(!btn)return;
26359      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26360      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>';
26361      document.body.appendChild(m);
26362      var g=document.getElementById('scheme-grid');
26363      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);});
26364      var cl=document.getElementById('settings-close');
26365      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);});})();
26366      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');});
26367      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26368      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26369    }
26370    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26371  }());
26372  </script>
26373  <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]';
26374  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;}
26375  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>
26376</body>
26377</html>
26378"##,
26379    ext = "html"
26380)]
26381struct ScanWaitTemplate {
26382    version: &'static str,
26383    wait_id_json: String,
26384    project_path: String,
26385    csp_nonce: String,
26386}
26387
26388#[derive(Template)]
26389#[template(
26390    source = r##"
26391<!doctype html>
26392<html lang="en">
26393<head>
26394  <meta charset="utf-8">
26395  <meta name="viewport" content="width=device-width, initial-scale=1">
26396  <title>OxideSLOC | Error</title>
26397  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26398  <style nonce="{{ csp_nonce }}">
26399    :root {
26400      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26401      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26402      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26403      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26404    }
26405    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26406    *{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;}
26407    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26408    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26409    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26410    .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);}
26411    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26412    .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));}
26413    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26414    .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;}
26415    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26416    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26417    @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; } }
26418    .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;}
26419    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26420    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26421    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26422    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26423    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26424    .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;}
26425    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26426    .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);}
26427    .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;}
26428    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26429    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26430    .settings-modal-body{padding:14px 16px 16px;}
26431    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26432    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26433    .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;}
26434    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26435    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26436    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26437    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26438    .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;}
26439    .tz-select:focus{border-color:var(--oxide);}
26440    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26441    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26442    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26443    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26444    .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;}
26445    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26446    .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);}
26447    .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;}
26448    .btn-secondary:hover{background:var(--line);}
26449    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26450    .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;}
26451    .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;}
26452    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26453    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26454    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26455    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26456    .bug-report-panel.open{display:flex;}
26457    .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;}
26458    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26459    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26460    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26461    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26462    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26463    .br-network-badge.online .br-net-dot{background:#2a6846;}
26464    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26465    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26466    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26467    .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;}
26468    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26469    .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;}
26470    .btn-sm:hover{background:var(--line);}
26471    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26472    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26473    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26474    .bug-report-hint a:hover{text-decoration:underline;}
26475    .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;}
26476    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26477    .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;}
26478    .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;}
26479    .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;}
26480    @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));}}
26481    .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;}
26482  </style>
26483</head>
26484<body>
26485  <div class="background-watermarks" aria-hidden="true">
26486    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26487    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26488    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26489    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26490    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26491    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26492  </div>
26493  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26494  <div class="top-nav">
26495    <div class="top-nav-inner">
26496      <a class="brand" href="/">
26497        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26498        <div class="brand-copy">
26499          <div class="brand-title">OxideSLOC</div>
26500          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26501        </div>
26502      </a>
26503      <div class="nav-right">
26504        <a class="nav-pill" href="/">Home</a>
26505        <div class="nav-dropdown">
26506          <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>
26507          <div class="nav-dropdown-menu">
26508            <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>
26509          </div>
26510        </div>
26511        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26512        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26513        <div class="nav-dropdown">
26514          <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>
26515          <div class="nav-dropdown-menu">
26516            <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>
26517          </div>
26518        </div>
26519        <div class="server-status-wrap" id="server-status-wrap">
26520          <div class="nav-pill server-online-pill" id="server-status-pill">
26521            <span class="status-dot" id="status-dot"></span>
26522            <span id="server-status-label">Server</span>
26523            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26524          </div>
26525          <div class="server-status-tip">
26526            OxideSLOC is running — accessible on your network.
26527            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26528          </div>
26529        </div>
26530        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26531          <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>
26532        </button>
26533        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26534          <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>
26535          <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>
26536        </button>
26537      </div>
26538    </div>
26539  </div>
26540
26541  <div class="page">
26542    <div class="panel">
26543      <h1>Error</h1>
26544      <div class="error-box" id="error-msg-text">{{ message }}</div>
26545      <div id="br-meta" hidden
26546        data-version="{{ version }}"
26547        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26548        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26549      <div class="actions">
26550        <a class="btn-primary" href="/scan">Back to setup</a>
26551        {% if let Some(report_url) = last_report_url %}
26552        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26553        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26554        {% else %}
26555        <a class="btn-secondary" href="/view-reports">View Reports</a>
26556        {% endif %}
26557      </div>
26558      <div class="bug-report-section" id="bug-report-section">
26559        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26560          <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>
26561          Generate Bug Report
26562          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26563        </button>
26564        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26565          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
26566          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
26567          <div class="bug-report-btns">
26568            <button type="button" class="btn-sm" id="bug-report-copy">
26569              <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>
26570              Copy to clipboard
26571            </button>
26572            <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;">
26573              <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>
26574              Open GitHub Issue
26575            </a>
26576            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26577              <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>
26578              Save as file
26579            </button>
26580          </div>
26581          <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>
26582          <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>
26583        </div>
26584      </div>
26585    </div>
26586  </div>
26587  <footer class="site-footer">
26588    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26589    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26590    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26591    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26592    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26593  </footer>
26594  <script nonce="{{ csp_nonce }}">(function(){
26595    var meta=document.getElementById('br-meta');
26596    var pre=document.getElementById('bug-report-pre');
26597    var copyBtn=document.getElementById('bug-report-copy');
26598    var trigger=document.getElementById('bug-report-trigger');
26599    var panel=document.getElementById('bug-report-panel');
26600    var networkBadge=document.getElementById('br-network-badge');
26601    var networkLabel=document.getElementById('br-network-label');
26602    var ghLink=document.getElementById('bug-report-github-link');
26603    var saveBtn=document.getElementById('bug-report-save');
26604    var hintOnline=document.getElementById('br-hint-online');
26605    var hintOffline=document.getElementById('br-hint-offline');
26606    if(!meta||!pre)return;
26607    var ver=meta.getAttribute('data-version')||'';
26608    var runId=meta.getAttribute('data-run-id')||'';
26609    var code=meta.getAttribute('data-error-code')||'';
26610    var msgEl=document.getElementById('error-msg-text');
26611    var msg=msgEl?msgEl.textContent.trim():'';
26612    function getBrowser(){
26613      var ua=navigator.userAgent;
26614      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26615      if(!m)return 'Unknown browser';
26616      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26617      return n+' '+m[2];
26618    }
26619    var lines=['oxide-sloc Bug Report','==============================',''];
26620    lines.push('App version:  v'+ver);
26621    if(code)lines.push('HTTP status:  '+code);
26622    if(runId)lines.push('Run ID:       '+runId);
26623    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
26624    lines.push('Timestamp:    '+new Date().toISOString());
26625    lines.push('Browser:      '+getBrowser());
26626    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
26627    lines.push('');
26628    lines.push('Error message:');
26629    lines.push(msg);
26630    lines.push('');
26631    lines.push('Steps to reproduce:');
26632    lines.push('  1. ');
26633    lines.push('');
26634    lines.push('Expected behavior:');
26635    lines.push('  ');
26636    pre.textContent=lines.join('\n');
26637    function applyNetwork(online){
26638      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26639      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26640      if(ghLink){
26641        if(online){
26642          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26643          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26644        }
26645        ghLink.style.display=online?'inline-flex':'none';
26646      }
26647      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26648      if(hintOnline)hintOnline.style.display=online?'block':'none';
26649      if(hintOffline)hintOffline.style.display=online?'none':'block';
26650    }
26651    applyNetwork(navigator.onLine);
26652    var probed=false;
26653    function probeNetwork(){
26654      if(probed)return;probed=true;
26655      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26656      var probeIdx=0;
26657      function tryNext(){
26658        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26659        var u=probeUrls[probeIdx++];
26660        var c2=new AbortController();
26661        var t2=setTimeout(function(){c2.abort();},4000);
26662        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26663          .then(function(){clearTimeout(t2);applyNetwork(true);})
26664          .catch(function(){clearTimeout(t2);tryNext();});
26665      }
26666      tryNext();
26667    }
26668    if(trigger&&panel){
26669      trigger.addEventListener('click',function(){
26670        var open=panel.classList.toggle('open');
26671        trigger.classList.toggle('open',open);
26672        trigger.setAttribute('aria-expanded',open?'true':'false');
26673        if(open)probeNetwork();
26674      });
26675    }
26676    if(copyBtn){
26677      copyBtn.addEventListener('click',function(){
26678        var txt=pre.textContent;
26679        if(navigator.clipboard&&navigator.clipboard.writeText){
26680          navigator.clipboard.writeText(txt).then(function(){
26681            copyBtn.textContent='\u2713 Copied!';
26682            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);
26683          });
26684        }else{
26685          var ta=document.createElement('textarea');
26686          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26687          document.body.appendChild(ta);ta.select();
26688          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26689          document.body.removeChild(ta);
26690        }
26691      });
26692    }
26693    if(saveBtn){
26694      saveBtn.addEventListener('click',function(){
26695        var txt=pre.textContent;
26696        var blob=new Blob([txt],{type:'text/plain'});
26697        var url=URL.createObjectURL(blob);
26698        var a=document.createElement('a');
26699        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26700        document.body.appendChild(a);a.click();
26701        document.body.removeChild(a);URL.revokeObjectURL(url);
26702      });
26703    }
26704  })();</script>
26705  <script nonce="{{ csp_nonce }}">
26706    (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");});})();
26707    (function spawnCodeParticles() {
26708      var container = document.getElementById('code-particles');
26709      if (!container) return;
26710      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'];
26711      for (var i = 0; i < 38; i++) {
26712        (function(idx) {
26713          var el = document.createElement('span');
26714          el.className = 'code-particle';
26715          el.textContent = snippets[idx % snippets.length];
26716          var left = Math.random() * 94 + 2;
26717          var top = Math.random() * 88 + 6;
26718          var dur = (Math.random() * 10 + 9).toFixed(1);
26719          var delay = (Math.random() * 18).toFixed(1);
26720          var rot = (Math.random() * 26 - 13).toFixed(1);
26721          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26722          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';
26723          container.appendChild(el);
26724        })(i);
26725      }
26726    })();
26727    (function randomizeWatermarks() {
26728      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26729      var placed = [];
26730      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; }
26731      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]; }
26732      var half = Math.floor(wms.length/2);
26733      wms.forEach(function(img, i) {
26734        var pos = pick(i < half);
26735        var w = Math.floor(Math.random()*60+80);
26736        var rot = (Math.random()*40-20).toFixed(1);
26737        var op = (Math.random()*0.08+0.05).toFixed(2);
26738        var animDur = (Math.random()*6+5).toFixed(1);
26739        var animDelay = (Math.random()*10).toFixed(1);
26740        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';
26741      });
26742    })();
26743  </script>
26744  <script nonce="{{ csp_nonce }}">
26745  (function(){
26746    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'}];
26747    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);});}
26748    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26749    function init(){
26750      var btn=document.getElementById('settings-btn');if(!btn)return;
26751      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26752      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>';
26753      document.body.appendChild(m);
26754      var g=document.getElementById('scheme-grid');
26755      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);});
26756      var cl=document.getElementById('settings-close');
26757      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);});})();
26758      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');});
26759      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26760      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26761    }
26762    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26763  }());
26764  </script>
26765  <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]';
26766  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;}
26767  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>
26768</body>
26769</html>
26770"##,
26771    ext = "html"
26772)]
26773struct ErrorTemplate {
26774    message: String,
26775    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
26776    last_report_url: Option<String>,
26777    /// Label for the secondary action button; defaults to "View last report" when None.
26778    last_report_label: Option<String>,
26779    /// Run ID to surface in the bug report; `None` when not applicable.
26780    run_id: Option<String>,
26781    /// HTTP status code to surface in the bug report; `None` when unknown.
26782    error_code: Option<u16>,
26783    csp_nonce: String,
26784    version: &'static str,
26785}
26786
26787// ── LocateFileTemplate ────────────────────────────────────────────────────────
26788
26789#[derive(Template)]
26790#[template(
26791    source = r##"
26792<!doctype html>
26793<html lang="en">
26794<head>
26795  <meta charset="utf-8">
26796  <meta name="viewport" content="width=device-width, initial-scale=1">
26797  <title>OxideSLOC | Locate Report</title>
26798  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26799  <style nonce="{{ csp_nonce }}">
26800    :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);}
26801    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26802    *{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;}
26803    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26804    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26805    .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);}
26806    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26807    .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));}
26808    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26809    .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;}
26810    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26811    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26812    @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;}}
26813    .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;}
26814    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26815    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26816    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26817    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26818    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26819    .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;}
26820    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26821    .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);}
26822    .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;}
26823    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26824    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26825    .settings-modal-body{padding:14px 16px 16px;}
26826    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26827    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26828    .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;}
26829    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26830    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26831    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26832    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26833    .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;}
26834    .tz-select:focus{border-color:var(--oxide);}
26835    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26836    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26837    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26838    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26839    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26840    .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;}
26841    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26842    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26843    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26844    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26845    .locate-row{display:flex;gap:8px;align-items:stretch;}
26846    .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;}
26847    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26848    body.dark-theme .locate-input{background:var(--surface-2);}
26849    .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;}
26850    .warning-banner.show{display:flex;}
26851    .warning-banner svg{flex:0 0 auto;}
26852    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26853    .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;}
26854    .error-inline.show{display:flex;}
26855    .error-inline svg{flex:0 0 auto;margin-top:2px;}
26856    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26857    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26858    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26859    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26860    .err-kv-p{margin:0 0 4px;}
26861    .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;}
26862    .success-inline.show{display:flex;}
26863    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26864    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26865    .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;}
26866    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26867    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26868    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26869    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26870    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26871    .fh-row:last-child{border-bottom:none;}
26872    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26873    .fh-dir{font-weight:800;color:var(--text);}
26874    .fh-hl{color:var(--oxide);font-weight:700;}
26875    .fh-muted{color:var(--muted);}
26876    .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;}
26877    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26878    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26879    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26880    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26881    .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;}
26882    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26883    .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;}
26884    .btn-secondary:hover{background:var(--line);}
26885    .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;}
26886    .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;}
26887    .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;}
26888    @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));}}
26889    .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;}
26890    .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;}
26891    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26892  </style>
26893</head>
26894<body>
26895  <div class="background-watermarks" aria-hidden="true">
26896    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26897    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26898    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26899    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26900    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26901    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26902  </div>
26903  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26904  <div class="top-nav">
26905    <div class="top-nav-inner">
26906      <a class="brand" href="/">
26907        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26908        <div class="brand-copy">
26909          <div class="brand-title">OxideSLOC</div>
26910          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26911        </div>
26912      </a>
26913      <div class="nav-right">
26914        <a class="nav-pill" href="/">Home</a>
26915        <div class="nav-dropdown">
26916          <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>
26917          <div class="nav-dropdown-menu">
26918            <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>
26919          </div>
26920        </div>
26921        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26922        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26923        <div class="nav-dropdown">
26924          <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>
26925          <div class="nav-dropdown-menu">
26926            <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>
26927          </div>
26928        </div>
26929        <div class="server-status-wrap" id="server-status-wrap">
26930          <div class="nav-pill server-online-pill" id="server-status-pill">
26931            <span class="status-dot" id="status-dot"></span>
26932            <span id="server-status-label">Server</span>
26933            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26934          </div>
26935          <div class="server-status-tip">
26936            OxideSLOC is running &mdash; accessible on your network.
26937            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26938          </div>
26939        </div>
26940        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26941          <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>
26942        </button>
26943        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26944          <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>
26945          <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>
26946        </button>
26947      </div>
26948    </div>
26949  </div>
26950
26951  <div class="page">
26952    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26953    <div class="panel">
26954      <h1>Report File Not Found</h1>
26955      <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>
26956      <div class="field-label">Missing file</div>
26957      <div class="filename-chip">
26958        <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>
26959        {{ expected_filename }}
26960      </div>
26961      <div class="locate-section">
26962        <h2>Locate Scan Output Folder</h2>
26963        <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>
26964        <p>OxideSLOC will find the correct files inside automatically.</p>
26965        <div class="locate-row">
26966          <input type="text" id="locate-file-input"
26967                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26968                 class="locate-input" autocomplete="off" spellcheck="false">
26969          {% if !server_mode %}
26970          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
26971          {% endif %}
26972        </div>
26973        <div class="warning-banner" id="filename-warning">
26974          <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>
26975          <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>
26976        </div>
26977        <div class="error-inline" id="locate-error">
26978          <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>
26979          <span id="locate-error-text"></span>
26980        </div>
26981        <div class="success-inline" id="locate-success">
26982          <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>
26983          <span>Scan restored &mdash; loading report&hellip;</span>
26984        </div>
26985        <div class="btn-row">
26986          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26987          <a class="btn-secondary" href="/view-reports">View Reports</a>
26988        </div>
26989        <div class="folder-hint-shell">
26990          <div class="folder-hint-hdr">
26991            <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>
26992            Expected Folder Structure &mdash; Select the Top-Level Folder
26993          </div>
26994          <div class="folder-hint-body">
26995            <div class="fh-row">
26996              <span class="fh-tog">&#9658;</span>
26997              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
26998              <span class="fh-badge">&larr; select this</span>
26999            </div>
27000            <div class="fh-row fh-i1">
27001              <span class="fh-tog">&#9658;</span>
27002              <span class="fh-dir">html/</span>
27003            </div>
27004            <div class="fh-row fh-i2">
27005              <span class="fh-bul">&#8226;</span>
27006              <span class="fh-hl">{{ expected_filename }}</span>
27007            </div>
27008            <div class="fh-row fh-i1">
27009              <span class="fh-tog">&#9658;</span>
27010              <span class="fh-dir">json/</span>
27011            </div>
27012            <div class="fh-row fh-i2">
27013              <span class="fh-bul">&#8226;</span>
27014              <span class="fh-muted">result_*.json</span>
27015            </div>
27016            <div class="fh-row fh-i1">
27017              <span class="fh-tog">&#9658;</span>
27018              <span class="fh-dir">pdf/</span>
27019            </div>
27020            <div class="fh-row fh-i2">
27021              <span class="fh-bul">&#8226;</span>
27022              <span class="fh-muted">report_*.pdf</span>
27023            </div>
27024            <div class="fh-row fh-i1">
27025              <span class="fh-tog">&#9658;</span>
27026              <span class="fh-dir">excel/</span>
27027            </div>
27028            <div class="fh-row fh-i2">
27029              <span class="fh-bul">&#8226;</span>
27030              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
27031            </div>
27032          </div>
27033        </div>
27034      </div>
27035    </div>
27036  </div>
27037  <footer class="site-footer">
27038    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
27039    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27040    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27041    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27042    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27043  </footer>
27044  <script nonce="{{ csp_nonce }}">(function(){
27045    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
27046    if(s==="dark")b.classList.add("dark-theme");
27047    document.getElementById("theme-toggle").addEventListener("click",function(){
27048      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
27049    });
27050  })();</script>
27051  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
27052    var c=document.getElementById('code-particles');if(!c)return;
27053    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'];
27054    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);}
27055  })();
27056  (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>
27057  <script nonce="{{ csp_nonce }}">(function(){
27058    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'}];
27059    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);});}
27060    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27061    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');});}
27062    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27063  }());</script>
27064  <script nonce="{{ csp_nonce }}">(function(){
27065    var meta=document.getElementById('locate-meta');
27066    var inp=document.getElementById('locate-file-input');
27067    var browseBtn=document.getElementById('browse-locate-btn');
27068    var submitBtn=document.getElementById('locate-submit-btn');
27069    var warning=document.getElementById('filename-warning');
27070    var errBox=document.getElementById('locate-error');
27071    var errText=document.getElementById('locate-error-text');
27072    var okBox=document.getElementById('locate-success');
27073    var expected=meta?meta.getAttribute('data-expected'):'';
27074    var runId=meta?meta.getAttribute('data-run-id'):'';
27075    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
27076    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
27077    function showErr(msg){
27078      if(errText){
27079        errText.innerHTML='';
27080        var lines=msg.split('\n');
27081        var hasPairs=lines.some(function(l){return / : /.test(l);});
27082        if(!hasPairs){errText.textContent=msg;}
27083        else{
27084          var frag=document.createDocumentFragment();var tbl=null;
27085          lines.forEach(function(line){
27086            var m=line.match(/^(.*?) : (.*)$/);
27087            if(m){
27088              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
27089              var tr=document.createElement('tr');
27090              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
27091              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
27092              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
27093            } else {
27094              tbl=null;
27095              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
27096            }
27097          });
27098          errText.appendChild(frag);
27099        }
27100      }
27101      if(errBox)errBox.classList.add('show');
27102      if(okBox)okBox.classList.remove('show');
27103    }
27104    function clearErr(){
27105      if(errBox)errBox.classList.remove('show');
27106      if(okBox)okBox.classList.remove('show');
27107    }
27108    function validate(){
27109      var val=inp?inp.value.trim():'';
27110      clearErr();
27111      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
27112      if(submitBtn)submitBtn.disabled=false;
27113      if(warning){
27114        var name=basename(val);
27115        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
27116        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
27117        else warning.classList.remove('show');
27118      }
27119    }
27120    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
27121    if(browseBtn){
27122      browseBtn.addEventListener('click',function(){
27123        browseBtn.disabled=true;browseBtn.textContent='...';
27124        fetch('/pick-directory')
27125          .then(function(r){return r.ok?r.json():{cancelled:true};})
27126          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
27127          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27128      });
27129    }
27130    if(submitBtn){
27131      submitBtn.addEventListener('click',function(){
27132        var folder=inp?inp.value.trim():'';
27133        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
27134        clearErr();
27135        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
27136        var body=new URLSearchParams();
27137        body.set('file_path',folder);
27138        body.set('redirect_url',redirectUrl);
27139        body.set('expected_run_id',runId);
27140        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27141          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
27142          .then(function(d){
27143            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27144            if(d&&d.ok){
27145              if(okBox)okBox.classList.add('show');
27146              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
27147            } else {
27148              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
27149            }
27150          })
27151          .catch(function(e){
27152            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
27153            showErr('Network error: '+String(e));
27154          });
27155      });
27156    }
27157  })();</script>
27158  <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>
27159</body>
27160</html>
27161"##,
27162    ext = "html"
27163)]
27164struct LocateFileTemplate {
27165    run_id: String,
27166    artifact_type: String,
27167    expected_filename: String,
27168    server_mode: bool,
27169    csp_nonce: String,
27170    version: &'static str,
27171}
27172
27173// ── RelocateScanTemplate ──────────────────────────────────────────────────────
27174
27175#[derive(Template)]
27176#[template(
27177    source = r##"
27178<!doctype html>
27179<html lang="en">
27180<head>
27181  <meta charset="utf-8">
27182  <meta name="viewport" content="width=device-width, initial-scale=1">
27183  <title>OxideSLOC | Locate Scan Files</title>
27184  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27185  <style nonce="{{ csp_nonce }}">
27186    :root {
27187      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
27188      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27189      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
27190      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27191    }
27192    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27193    *{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;}
27194    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27195    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27196    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
27197    .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);}
27198    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27199    .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));}
27200    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27201    .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;}
27202    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27203    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
27204    @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;}}
27205    .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;}
27206    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27207    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27208    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27209    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27210    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27211    .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;}
27212    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27213    .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);}
27214    .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;}
27215    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27216    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27217    .settings-modal-body{padding:14px 16px 16px;}
27218    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27219    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27220    .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;}
27221    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27222    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27223    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27224    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27225    .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;}
27226    .tz-select:focus{border-color:var(--oxide);}
27227    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
27228    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
27229    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
27230    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
27231    .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;}
27232    .error-box.hidden{display:none;}
27233    .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;}
27234    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
27235    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
27236    .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;}
27237    .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;}
27238    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
27239    .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;}
27240    .btn-secondary:hover{background:var(--line);}
27241    .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;}
27242    .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;}
27243    .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;}
27244    @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));}}
27245    .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;}
27246    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
27247    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
27248    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
27249    .relocate-row{display:flex;gap:8px;align-items:stretch;}
27250    .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;}
27251    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
27252    body.dark-theme .relocate-input{background:var(--surface-2);}
27253  </style>
27254</head>
27255<body>
27256  <div class="background-watermarks" aria-hidden="true">
27257    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27258    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27259    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27260    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27261    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27262    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27263  </div>
27264  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27265  <div class="top-nav">
27266    <div class="top-nav-inner">
27267      <a class="brand" href="/">
27268        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
27269        <div class="brand-copy">
27270          <div class="brand-title">OxideSLOC</div>
27271          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
27272        </div>
27273      </a>
27274      <div class="nav-right">
27275        <a class="nav-pill" href="/">Home</a>
27276        <div class="nav-dropdown">
27277          <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>
27278          <div class="nav-dropdown-menu">
27279            <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>
27280          </div>
27281        </div>
27282        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27283        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27284        <div class="nav-dropdown">
27285          <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>
27286          <div class="nav-dropdown-menu">
27287            <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>
27288          </div>
27289        </div>
27290        <div class="server-status-wrap" id="server-status-wrap">
27291          <div class="nav-pill server-online-pill" id="server-status-pill">
27292            <span class="status-dot" id="status-dot"></span>
27293            <span id="server-status-label">Server</span>
27294            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27295          </div>
27296          <div class="server-status-tip">
27297            OxideSLOC is running — accessible on your network.
27298            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27299          </div>
27300        </div>
27301        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27302          <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>
27303        </button>
27304        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27305          <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>
27306          <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>
27307        </button>
27308      </div>
27309    </div>
27310  </div>
27311
27312  <div class="page">
27313    <div class="panel">
27314      <h1>Scan Files Moved</h1>
27315      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
27316      <div class="error-box" id="relocate-error-box">{{ message }}</div>
27317      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
27318      <div class="relocate-section">
27319        <h2>Locate Scan Output</h2>
27320        <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>
27321        <div class="relocate-row">
27322          <input type="text" id="relocate-folder" name="folder_path"
27323                 value="{{ folder_hint }}"
27324                 placeholder="Path to folder containing scan output..."
27325                 class="relocate-input" autocomplete="off" spellcheck="false">
27326          {% if !server_mode %}
27327          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
27328          {% endif %}
27329        </div>
27330        <div style="margin-top:12px;">
27331          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27332        </div>
27333      </div>
27334      <div class="actions">
27335        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27336        <a class="btn-secondary" href="/view-reports">View Reports</a>
27337      </div>
27338    </div>
27339  </div>
27340  <footer class="site-footer">
27341    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
27342    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27343    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27344    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27345    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27346  </footer>
27347  <script nonce="{{ csp_nonce }}">
27348    (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");});})();
27349    (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);}})();
27350    (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;});})();
27351  </script>
27352  <script nonce="{{ csp_nonce }}">
27353  (function(){
27354    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'}];
27355    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);});}
27356    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27357    function init(){
27358      var btn=document.getElementById('settings-btn');if(!btn)return;
27359      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27360      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>';
27361      document.body.appendChild(m);
27362      var g=document.getElementById('scheme-grid');
27363      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);});
27364      var cl=document.getElementById('settings-close');
27365      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);});})();
27366      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');});
27367      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27368      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27369    }
27370    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27371  }());
27372  (function(){
27373    var browseBtn=document.getElementById('browse-relocate-btn');
27374    if(browseBtn){
27375      browseBtn.addEventListener('click',function(){
27376        browseBtn.disabled=true;browseBtn.textContent='...';
27377        var inp=document.getElementById('relocate-folder');
27378        var hint=inp?inp.value:'';
27379        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
27380          .then(function(r){return r.ok?r.json():{cancelled:true};})
27381          .then(function(d){
27382            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27383            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27384          })
27385          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27386      });
27387    }
27388    var restoreBtn=document.getElementById('restore-btn');
27389    var errBox=document.getElementById('relocate-error-box');
27390    var okBox=document.getElementById('relocate-success-box');
27391    if(restoreBtn){
27392      restoreBtn.addEventListener('click',function(){
27393        var inp=document.getElementById('relocate-folder');
27394        var folder=inp?inp.value.trim():'';
27395        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27396        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27397        var body=new URLSearchParams();
27398        body.set('run_id','{{ run_id }}');
27399        body.set('redirect_url','{{ redirect_url }}');
27400        body.set('folder_path',folder);
27401        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27402          .then(function(r){return r.json();})
27403          .then(function(d){
27404            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27405            if(d&&d.ok){
27406              if(errBox)errBox.classList.add('hidden');
27407              if(okBox){okBox.style.display='block';}
27408              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27409            } else {
27410              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27411            }
27412          })
27413          .catch(function(e){
27414            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27415            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27416          });
27417      });
27418    }
27419  }());
27420  </script>
27421  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
27422  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;}
27423  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>
27424</body>
27425</html>
27426"##,
27427    ext = "html"
27428)]
27429struct RelocateScanTemplate {
27430    message: String,
27431    run_id: String,
27432    folder_hint: String,
27433    redirect_url: String,
27434    server_mode: bool,
27435    csp_nonce: String,
27436    version: &'static str,
27437}
27438
27439// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
27440
27441#[derive(Template)]
27442#[template(
27443    source = r##"
27444<!doctype html>
27445<html lang="en">
27446<head>
27447  <meta charset="utf-8">
27448  <meta name="viewport" content="width=device-width, initial-scale=1">
27449  <title>OxideSLOC | View Reports</title>
27450  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27451  <style nonce="{{ csp_nonce }}">
27452    :root {
27453      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27454      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27455      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27456      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27457      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27458    }
27459    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; }
27460    *{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;}
27461    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27462    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27463    .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);}
27464    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27465    .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));}
27466    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27467    .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;}
27468    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27469    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27470    @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; } }
27471    .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;}
27472    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27473    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27474    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27475    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27476    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27477    .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;}
27478    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27479    .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);}
27480    .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;}
27481    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27482    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27483    .settings-modal-body{padding:14px 16px 16px;}
27484    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27485    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27486    .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;}
27487    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27488    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27489    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27490    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27491    .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;}
27492    .tz-select:focus{border-color:var(--oxide);}
27493    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27494    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27495    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27496    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27497    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27498    .panel-meta{font-size:13px;color:var(--muted);}
27499    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27500    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27501    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27502    .per-page-label{font-size:13px;color:var(--muted);}
27503    select.per-page,.filter-input,.filter-select{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
27504    .filter-input{min-width:180px;cursor:text;}
27505    .table-wrap{width:100%;overflow-x:auto;}
27506    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27507    th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
27508    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27509    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27510    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27511    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27512    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27513    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27514    tr:last-child td{border-bottom:none;}
27515    tr:hover td{background:var(--surface-2);}
27516    .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);}
27517    .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);}
27518    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27519    .metric-num{font-weight:700;color:var(--text);}
27520    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27521    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27522    .git-commit-chip{cursor:help;}
27523    .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;}
27524    .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;}
27525    .btn:hover{background:var(--line);}
27526    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27527    .btn.primary:hover{opacity:.9;}
27528    .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;}
27529    .btn-back:hover{background:var(--line);}
27530    .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;}
27531    .export-btn:hover{background:var(--line);}
27532    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27533    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27534    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27535    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27536    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27537    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27538    .pagination-info{font-size:13px;color:var(--muted);}
27539    .pagination-btns{display:flex;gap:6px;}
27540    .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;}
27541    .pg-btn:hover:not(:disabled){background:var(--line);}
27542    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27543    .pg-btn:disabled{opacity:.35;cursor:default;}
27544    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27545    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27546    .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);}
27547    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27548    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27549    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27550    .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);}
27551    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27552    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27553    .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;}
27554    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27555    .site-footer a{color:var(--muted);}
27556    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27557    .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%;}
27558    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27559    .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;}
27560    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27561    .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;}
27562    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27563    .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;}
27564    .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;}
27565    .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;}
27566    @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));}}
27567    .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;}
27568    .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;}
27569    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27570    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27571    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27572    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27573    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27574    .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;}
27575    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27576    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27577    .watched-chip-rm:hover{color:var(--oxide);}
27578    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27579    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27580    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27581    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27582    .rpt-btn{min-width:58px;justify-content:center;}
27583    .flex-row{display:flex;align-items:center;gap:8px;}
27584    .report-cell{overflow:visible;white-space:normal;}
27585    #history-table col:nth-child(1){width:185px;}
27586    #history-table col:nth-child(2){width:220px;}
27587    #history-table col:nth-child(3){width:100px;}
27588    #history-table col:nth-child(4){width:72px;}
27589    #history-table col:nth-child(5){width:82px;}
27590    #history-table col:nth-child(6){width:82px;}
27591    #history-table col:nth-child(7){width:65px;}
27592    #history-table col:nth-child(8){width:90px;}
27593    #history-table col:nth-child(9){width:85px;}
27594    #history-table col:nth-child(10){width:115px;}
27595    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27596    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27597    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27598    .submod-details summary::-webkit-details-marker{display:none;}
27599.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27600    .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;}
27601    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27602    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27603  </style>
27604</head>
27605<body>
27606  <div class="background-watermarks" aria-hidden="true">
27607    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27608    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27609    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27610    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27611    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27612    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27613  </div>
27614  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27615  <div class="top-nav">
27616    <div class="top-nav-inner">
27617      <a class="brand" href="/">
27618        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27619        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27620      </a>
27621      <div class="nav-right">
27622        <a class="nav-pill" href="/">Home</a>
27623        <div class="nav-dropdown">
27624          <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>
27625          <div class="nav-dropdown-menu">
27626            <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>
27627          </div>
27628        </div>
27629        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27630        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27631        <div class="nav-dropdown">
27632          <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>
27633          <div class="nav-dropdown-menu">
27634            <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>
27635          </div>
27636        </div>
27637        <div class="server-status-wrap" id="server-status-wrap">
27638          <div class="nav-pill server-online-pill" id="server-status-pill">
27639            <span class="status-dot" id="status-dot"></span>
27640            <span id="server-status-label">Server</span>
27641            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27642          </div>
27643          <div class="server-status-tip">
27644            OxideSLOC is running — accessible on your network.
27645            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27646          </div>
27647        </div>
27648        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27649          <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>
27650        </button>
27651        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27652          <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>
27653          <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>
27654        </button>
27655      </div>
27656    </div>
27657  </div>
27658
27659  <div class="page">
27660    {% if let Some(err) = browse_error %}
27661    <div class="toast-error">
27662      <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>
27663      {{ err }}
27664    </div>
27665    {% endif %}
27666    {% if linked_count > 0 %}
27667    <div class="toast-success">
27668      <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>
27669      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27670    </div>
27671    {% endif %}
27672    <div class="watched-bar">
27673      <div class="watched-bar-left">
27674        <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>
27675        <span class="watched-label">Watched Folders</span>
27676        <div class="watched-chips">
27677          {% if server_mode %}
27678          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27679          {% else %}
27680          {% for dir in watched_dirs %}
27681          <span class="watched-chip">
27682            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27683            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27684              <input type="hidden" name="folder_path" value="{{ dir }}">
27685              <input type="hidden" name="redirect_to" value="/view-reports">
27686              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27687            </form>
27688          </span>
27689          {% endfor %}
27690          {% if watched_dirs.is_empty() %}
27691          <span class="watched-none">No folders watched — click Choose to add one</span>
27692          {% endif %}
27693          {% endif %}
27694        </div>
27695      </div>
27696      {% if !server_mode %}
27697      <div class="watched-bar-right">
27698        <button type="button" class="btn" id="add-watched-btn">
27699          <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>
27700          Choose
27701        </button>
27702        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27703          <input type="hidden" name="redirect_to" value="/view-reports">
27704          <button type="submit" class="btn">&#8635; Refresh</button>
27705        </form>
27706      </div>
27707      {% endif %}
27708    </div>
27709    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27710      <div class="scan-overlay-card">
27711        <div class="scan-spinner"></div>
27712        <div class="scan-overlay-text">Scanning folder…</div>
27713        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27714      </div>
27715    </div>
27716    <style>
27717    .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);}
27718    .scan-overlay.active{display:flex;}
27719    .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;}
27720    .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;}
27721    @keyframes scanSpin{to{transform:rotate(360deg);}}
27722    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27723    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27724    </style>
27725    {% if total_scans > 0 %}
27726    <div class="summary-strip">
27727      <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>
27728      <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>
27729      <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>
27730      <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>
27731    </div>
27732    {% endif %}
27733
27734    <section class="panel">
27735      <div class="panel-header">
27736        <div>
27737          <h1>View Reports</h1>
27738          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27739          {% 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 %}
27740        </div>
27741        <div class="flex-row">
27742          <button type="button" class="export-btn" id="export-csv-btn">
27743            <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>
27744            Export CSV
27745          </button>
27746          <button type="button" class="export-btn" id="export-xls-btn">
27747            <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>
27748            Export Excel
27749          </button>
27750        </div>
27751      </div>
27752
27753      {% if entries.is_empty() %}
27754      <div class="empty-state">
27755        <strong>No reports with viewable HTML yet</strong>
27756        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.
27757      </div>
27758      {% else %}
27759      <div class="filter-row">
27760        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27761        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27762        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27763      </div>
27764      <div class="table-wrap">
27765        <table id="history-table">
27766          <colgroup>
27767            <col><col><col><col><col><col><col><col><col><col>
27768          </colgroup>
27769          <thead>
27770            <tr id="history-thead">
27771              <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>
27772              <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>
27773              <th>Run ID<div class="col-resize-handle"></div></th>
27774              <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>
27775              <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>
27776              <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>
27777              <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>
27778              <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>
27779              <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>
27780              <th>Report<div class="col-resize-handle"></div></th>
27781            </tr>
27782          </thead>
27783          <tbody id="history-tbody">
27784            {% for entry in entries %}
27785            <tr class="history-row" data-run="{{ entry.run_id }}"
27786                data-timestamp="{{ entry.timestamp }}"
27787                data-project="{{ entry.project_label }}"
27788                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27789                data-skipped="{{ entry.files_skipped }}"
27790                data-comments="{{ entry.comment_lines }}"
27791                data-blank="{{ entry.blank_lines }}"
27792                data-physical="{{ entry.total_physical_lines }}"
27793                data-functions="{{ entry.functions }}"
27794                data-classes="{{ entry.classes }}"
27795                data-variables="{{ entry.variables }}"
27796                data-imports="{{ entry.imports }}"
27797                data-tests="{{ entry.test_count }}"
27798                data-branch="{{ entry.git_branch }}"
27799                data-commit="{{ entry.git_commit }}"
27800                data-has-json="{{ entry.has_json }}"
27801                data-html-url="/runs/html/{{ entry.run_id }}">
27802              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27803              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27804              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27805              <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>
27806              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27807              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27808              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27809              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
27810              <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>
27811              <td class="report-cell">
27812                <div class="actions-cell">
27813                  {% 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 %}
27814                  {% 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 %}
27815                </div>
27816                {% if !entry.submodule_links.is_empty() %}
27817                <details class="submod-details">
27818                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
27819                  <div class="submod-link-list">
27820                    {% for sub in entry.submodule_links %}
27821                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27822                    {% endfor %}
27823                  </div>
27824                </details>
27825                {% endif %}
27826              </td>
27827            </tr>
27828            {% endfor %}
27829          </tbody>
27830        </table>
27831      </div>
27832      <div class="pagination">
27833        <span class="pagination-info" id="pagination-info"></span>
27834        <div class="pagination-btns" id="pagination-btns"></div>
27835        <div class="flex-row">
27836          <span class="per-page-label">Show</span>
27837          <select class="per-page" id="per-page-sel">
27838            <option value="10">10 per page</option>
27839            <option value="25" selected>25 per page</option>
27840            <option value="50">50 per page</option>
27841            <option value="100">100 per page</option>
27842          </select>
27843          <span class="per-page-label" id="page-range-label"></span>
27844        </div>
27845      </div>
27846      {% endif %}
27847    </section>
27848  </div>
27849
27850  <footer class="site-footer">
27851    local code analysis - metrics, history and reports
27852    &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>
27853    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27854    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27855    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27856    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27857  </footer>
27858
27859  <script nonce="{{ csp_nonce }}">
27860    (function () {
27861      // ── Theme ──────────────────────────────────────────────────────────────
27862      var storageKey = 'oxide-sloc-theme';
27863      var body = document.body;
27864      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27865      var toggle = document.getElementById('theme-toggle');
27866      if (toggle) toggle.addEventListener('click', function () {
27867        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27868        body.classList.toggle('dark-theme', next === 'dark');
27869        try { localStorage.setItem(storageKey, next); } catch(e) {}
27870      });
27871
27872      // ── State ─────────────────────────────────────────────────────────────
27873      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27874      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27875      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27876
27877      // Aggregate stats from first (most recent) row
27878      if (allRows.length) {
27879        var first = allRows[0];
27880        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();}
27881        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>':'');}
27882        setChipVal('agg-code', first.dataset.code);
27883        setChipVal('agg-files', first.dataset.files);
27884        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27885        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27886        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(); });
27887      }
27888
27889      // ── Branch filter population ──────────────────────────────────────────
27890      (function() {
27891        var branches = {};
27892        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27893        var sel = document.getElementById('branch-filter');
27894        if (sel) Object.keys(branches).sort().forEach(function(b) {
27895          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27896        });
27897      })();
27898
27899      // ── Filter ────────────────────────────────────────────────────────────
27900      function getFilteredRows() {
27901        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27902        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27903        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27904          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27905          if (branch && (r.dataset.branch || '') !== branch) return false;
27906          return true;
27907        });
27908      }
27909
27910      // ── Pagination ────────────────────────────────────────────────────────
27911      function renderPage() {
27912        var filtered = getFilteredRows();
27913        var total = filtered.length;
27914        var totalPages = Math.max(1, Math.ceil(total / perPage));
27915        currentPage = Math.min(currentPage, totalPages);
27916        var start = (currentPage - 1) * perPage;
27917        var end = Math.min(start + perPage, total);
27918        var shown = {};
27919        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27920        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27921          r.style.display = shown[r.dataset.run] ? '' : 'none';
27922        });
27923        var rl = document.getElementById('page-range-label');
27924        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27925        var info = document.getElementById('pagination-info');
27926        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27927        var btns = document.getElementById('pagination-btns');
27928        if (!btns) return;
27929        btns.innerHTML = '';
27930        function makeBtn(lbl, pg, active, disabled) {
27931          var b = document.createElement('button');
27932          b.className = 'pg-btn' + (active ? ' active' : '');
27933          b.textContent = lbl; b.disabled = disabled;
27934          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27935          return b;
27936        }
27937        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27938        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27939        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27940        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27941      }
27942
27943      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27944      window.applyFilters = function() { currentPage = 1; renderPage(); };
27945
27946      // ── Sorting ───────────────────────────────────────────────────────────
27947      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27948      function doSort(col, type, order) {
27949        var tbody = document.getElementById('history-tbody');
27950        if (!tbody) return;
27951        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27952        rows.sort(function(a, b) {
27953          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27954          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27955          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27956          return va < vb ? 1 : va > vb ? -1 : 0;
27957        });
27958        rows.forEach(function(r) { tbody.appendChild(r); });
27959        currentPage = 1; renderPage();
27960      }
27961      sortHeaders.forEach(function(th) {
27962        th.addEventListener('click', function(e) {
27963          if (e.target.classList.contains('col-resize-handle')) return;
27964          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27965          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27966          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27967          th.classList.add('sort-' + sortOrder);
27968          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27969          doSort(col, type, sortOrder);
27970        });
27971      });
27972
27973      // ── Column resize ─────────────────────────────────────────────────────
27974      (function() {
27975        var table = document.getElementById('history-table');
27976        if (!table) return;
27977        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27978        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27979        ths.forEach(function(th, i) {
27980          var handle = th.querySelector('.col-resize-handle');
27981          if (!handle || !cols[i]) return;
27982          var startX, startW;
27983          handle.addEventListener('mousedown', function(e) {
27984            e.stopPropagation(); e.preventDefault();
27985            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27986            handle.classList.add('dragging');
27987            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27988            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27989            document.addEventListener('mousemove', onMove);
27990            document.addEventListener('mouseup', onUp);
27991          });
27992        });
27993      })();
27994
27995      // ── Full-commit hover tooltip ─────────────────────────────────────────
27996      // The commit chips live inside an overflow:auto table wrapper, which would
27997      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27998      // (escaping the scroll container) and follow the cursor. Event delegation
27999      // keeps it working after pagination/sorting re-renders the rows.
28000      (function() {
28001        var tip = document.createElement('div');
28002        tip.className = 'commit-tip';
28003        tip.setAttribute('role', 'tooltip');
28004        document.body.appendChild(tip);
28005        var shown = false;
28006        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28007        function place(e) {
28008          var pad = 14, r = tip.getBoundingClientRect();
28009          var x = e.clientX + pad, y = e.clientY + pad;
28010          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28011          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28012          tip.style.left = x + 'px'; tip.style.top = y + 'px';
28013        }
28014        function hide() { tip.style.display = 'none'; shown = false; }
28015        document.addEventListener('mouseover', function(e) {
28016          var chip = chipFrom(e.target);
28017          if (!chip) return;
28018          var full = chip.getAttribute('data-full-commit');
28019          if (!full) return;
28020          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28021        });
28022        document.addEventListener('mousemove', function(e) {
28023          if (!shown) return;
28024          if (chipFrom(e.target)) place(e); else hide();
28025        });
28026        document.addEventListener('mouseout', function(e) {
28027          if (chipFrom(e.target)) hide();
28028        });
28029      })();
28030
28031      // ── Reset view ────────────────────────────────────────────────────────
28032      window.resetView = function() {
28033        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28034        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28035        sortCol = null; sortOrder = 'asc';
28036        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28037        var tbody = document.getElementById('history-tbody');
28038        if (tbody) {
28039          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
28040          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28041          rows.forEach(function(r) { tbody.appendChild(r); });
28042        }
28043        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28044        var table = document.getElementById('history-table');
28045        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
28046        currentPage = 1; renderPage();
28047      };
28048
28049      renderPage();
28050
28051      // ── Export helpers ────────────────────────────────────────────────────
28052      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
28053      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
28054      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);}
28055      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;');}
28056      function slocXlsx(fname,sheet,hdrs,rows){
28057        var enc=new TextEncoder();
28058        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;}
28059        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;}
28060        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
28061        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
28062        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
28063        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;}
28064        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
28065        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];}
28066        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
28067        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
28068        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
28069          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
28070          +'<fonts count="2">'
28071            +'<font><sz val="11"/><name val="Calibri"/></font>'
28072            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
28073          +'</fonts>'
28074          +'<fills count="3">'
28075            +'<fill><patternFill patternType="none"/></fill>'
28076            +'<fill><patternFill patternType="gray125"/></fill>'
28077            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
28078          +'</fills>'
28079          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
28080          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
28081          +'<cellXfs count="4">'
28082            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
28083            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28084            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
28085            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
28086          +'</cellXfs>'
28087          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
28088          +'</styleSheet>';
28089        var rx='<row r="1">';
28090        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
28091        rx+='</row>';
28092        rows.forEach(function(row,ri){
28093          var rn=ri+2;rx+='<row r="'+rn+'">';
28094          row.forEach(function(cell,c){
28095            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
28096            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
28097            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
28098            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
28099            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
28100            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
28101          });
28102          rx+='</row>';
28103        });
28104        var lastCol=hdrs.length,lastRow=rows.length+1;
28105        var tableRef='A1:'+colNm(lastCol)+lastRow;
28106        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28107          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
28108          +'<autoFilter ref="'+tableRef+'"/>'
28109          +'<tableColumns count="'+lastCol+'">'
28110          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28111          +'</tableColumns>'
28112          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28113          +'</table>';
28114        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28115          +'<Relationships xmlns="'+pns+'relationships">'
28116          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
28117          +'</Relationships>';
28118        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>';
28119        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28120          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28121          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
28122          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
28123          +'</worksheet>';
28124        var F={
28125          '[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>',
28126          '_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>',
28127          '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>',
28128          '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>',
28129          'xl/styles.xml':stl,
28130          'xl/sharedStrings.xml':ssXml,
28131          'xl/worksheets/sheet1.xml':sh,
28132          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
28133          'xl/tables/table1.xml':tableXml
28134        };
28135        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'];
28136        var zparts=[],zcds=[],zoff=0,znf=0;
28137        order.forEach(function(name){
28138          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
28139          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]);
28140          var entry=new Uint8Array(lha.length+nb.length+sz);
28141          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
28142          zparts.push(entry);
28143          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));
28144          var cde=new Uint8Array(cda.length+nb.length);
28145          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
28146          zcds.push(cde);zoff+=entry.length;znf++;
28147        });
28148        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28149        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]);
28150        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
28151        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28152        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28153        zout.set(new Uint8Array(ea),zpos);
28154        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28155      }
28156
28157      // Multi-sheet XLSX builder for the scan-history export.
28158      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
28159      function slocXlsxMulti(fname,sheets){
28160        var enc=new TextEncoder();
28161        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;}
28162        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;}
28163        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
28164        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
28165        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
28166        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];}
28167        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;}
28168        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
28169        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
28170        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
28171          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
28172          +'<fonts count="3">'
28173            +'<font><sz val="11"/><name val="Calibri"/></font>'
28174            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
28175            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
28176          +'</fonts>'
28177          +'<fills count="4">'
28178            +'<fill><patternFill patternType="none"/></fill>'
28179            +'<fill><patternFill patternType="gray125"/></fill>'
28180            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
28181            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
28182          +'</fills>'
28183          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
28184          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
28185          +'<cellXfs count="7">'
28186            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
28187            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28188            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
28189            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
28190            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
28191            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
28192            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
28193          +'</cellXfs>'
28194          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
28195          +'</styleSheet>';
28196        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
28197        sheets.forEach(function(sh,sheetIdx){
28198          var rx='<row r="1">';
28199          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
28200          rx+='</row>';
28201          var rn=2;
28202          sh.rows.forEach(function(row){
28203            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
28204            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
28205              rx+='<row r="'+rn+'">';
28206              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
28207              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
28208              rx+='</row>';rn++;return;
28209            }
28210            rx+='<row r="'+rn+'">';
28211            row.forEach(function(cell,c){
28212              var ref=colRef(c,rn);
28213              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
28214              if(typeof cell==='object'&&cell!==null){
28215                var cv=cell.v,cs=cell.s!=null?cell.s:0;
28216                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
28217                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
28218                return;
28219              }
28220              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
28221              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
28222            });
28223            rx+='</row>';rn++;
28224          });
28225          var cw='';
28226          if(sh.colWidths&&sh.colWidths.length>0){
28227            cw='<cols>';
28228            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
28229            cw+='</cols>';
28230          }
28231          var tblParts='';
28232          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
28233            tableCounter++;
28234            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
28235            var tRef='A1:'+colNm(colCount)+rowCount;
28236            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28237              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
28238              +'<autoFilter ref="'+tRef+'"/>'
28239              +'<tableColumns count="'+colCount+'">'
28240              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
28241              +'</tableColumns>'
28242              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
28243              +'</table>';
28244            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
28245              +'<Relationships xmlns="'+pns+'relationships">'
28246              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
28247              +'</Relationships>';
28248            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
28249          }
28250          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
28251            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
28252            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
28253        });
28254        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>';
28255        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
28256        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
28257        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>';
28258        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
28259        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
28260        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
28261        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
28262          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
28263        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
28264        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};
28265        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
28266        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
28267        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
28268        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
28269        var zparts=[],zcds=[],zoff=0,znf=0;
28270        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++;});
28271        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
28272        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]);
28273        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
28274        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
28275        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
28276        zout.set(new Uint8Array(ea),zpos);
28277        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
28278      }
28279
28280      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'};
28281      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
28282
28283      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'];
28284      function getHistoryRows(){
28285        var r=[];
28286        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
28287          var code=Number(tr.getAttribute('data-code'))||0;
28288          var phys=Number(tr.getAttribute('data-physical'))||0;
28289          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28290          r.push([
28291            tr.getAttribute('data-timestamp')||'',
28292            tr.getAttribute('data-project')||'',
28293            tr.getAttribute('data-run')||'',
28294            tr.getAttribute('data-physical')||'',
28295            tr.getAttribute('data-code')||'',
28296            tr.getAttribute('data-comments')||'',
28297            tr.getAttribute('data-blank')||'',
28298            tr.getAttribute('data-files')||'',
28299            tr.getAttribute('data-skipped')||'',
28300            tr.getAttribute('data-functions')||'',
28301            tr.getAttribute('data-classes')||'',
28302            tr.getAttribute('data-variables')||'',
28303            tr.getAttribute('data-imports')||'',
28304            tr.getAttribute('data-tests')||'',
28305            dens,
28306            tr.getAttribute('data-branch')||'',
28307            tr.getAttribute('data-commit')||''
28308          ]);
28309        });
28310        return r;
28311      }
28312      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
28313      window.exportHistoryXls = function(){
28314        var histRows=getHistoryRows();
28315        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
28316        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]];});
28317        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]};
28318        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
28319        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
28320        var runId=jsonRow.getAttribute('data-run')||'';
28321        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
28322        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
28323        fetch('/runs/json/'+runId)
28324          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
28325          .then(function(run){
28326            var tot=run.summary_totals||{};
28327            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28328            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28329            function B(v){return{v:v,s:4};}
28330            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28331            var sumRows=[
28332              [{_sec:true,v:'RUN INFORMATION'}],
28333              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28334              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28335              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28336              [B('Branch'),run.git_branch||''],
28337              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28338              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28339              [B('Files Analyzed'),N(tot.files_analyzed)],
28340              [B('Files Skipped'),N(tot.files_skipped)],
28341              [],
28342              [{_sec:true,v:'CODE METRICS'}],
28343              [B('Physical Lines'),N(phys)],
28344              [B('Code Lines'),N(code)],
28345              [B('Comments'),N(tot.comment_lines)],
28346              [B('Blank Lines'),N(tot.blank_lines)],
28347              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28348              [B('Functions'),N(tot.functions)],
28349              [B('Classes / Types'),N(tot.classes)],
28350              [B('Variables'),N(tot.variables)],
28351              [B('Imports'),N(tot.imports)],
28352              [B('Tests'),N(tot.test_count)],
28353              [B('Assertions'),N(tot.test_assertion_count)],
28354              [B('Test Suites'),N(tot.test_suite_count)],
28355              [B('Code Density'),{v:dens,s:6}],
28356              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28357            ];
28358            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28359            var langRows=(run.totals_by_language||[]).map(function(l){
28360              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28361              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28362              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];
28363            });
28364            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28365            var pfRows=(run.per_file_records||[]).map(function(r){
28366              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28367              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];
28368            });
28369            var skHdrs=['File','Status','Size (bytes)'];
28370            var skRows=(run.skipped_file_records||[]).map(function(r){
28371              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28372            });
28373            slocXlsxMulti('scan-history.xlsx',[
28374              histSheet,
28375              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28376              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28377              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28378              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28379            ]);
28380          })
28381          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28382      };
28383
28384      var csvBtn = document.getElementById('export-csv-btn');
28385      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28386      var xlsBtn = document.getElementById('export-xls-btn');
28387      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28388
28389      // ── Remaining CSP-safe event bindings ────────────────────────────────
28390      (function wireEvents() {
28391        var el;
28392        el = document.getElementById('reset-view-btn');
28393        if (el) el.addEventListener('click', window.resetView);
28394        el = document.getElementById('project-filter');
28395        if (el) el.addEventListener('input', window.applyFilters);
28396        el = document.getElementById('branch-filter');
28397        if (el) el.addEventListener('change', window.applyFilters);
28398        el = document.getElementById('per-page-sel');
28399        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28400        (function(){
28401          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');};
28402          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);
28403        })();
28404        el = document.getElementById('add-watched-btn');
28405        if (el) el.addEventListener('click', function() {
28406          fetch('/pick-directory?kind=reports')
28407            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28408            .then(function(data) {
28409              if (!data.cancelled && data.selected_path) {
28410                var form = document.createElement('form');
28411                form.method = 'POST';
28412                form.action = '/watched-dirs/add';
28413                var ri = document.createElement('input');
28414                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28415                var fi = document.createElement('input');
28416                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28417                form.appendChild(ri); form.appendChild(fi);
28418                document.body.appendChild(form);
28419                if (window.__scanOverlay) window.__scanOverlay();
28420                form.submit();
28421              }
28422            })
28423            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28424        });
28425      })();
28426
28427      (function randomizeWatermarks() {
28428        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28429        if (!wms.length) return;
28430        var placed = [];
28431        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;}
28432        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];}
28433        var half=Math.floor(wms.length/2);
28434        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;});
28435      })();
28436
28437      (function spawnCodeParticles() {
28438        var container = document.getElementById('code-particles');
28439        if (!container) return;
28440        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'];
28441        for (var i = 0; i < 38; i++) {
28442          (function(idx) {
28443            var el = document.createElement('span');
28444            el.className = 'code-particle';
28445            el.textContent = snippets[idx % snippets.length];
28446            var left = Math.random() * 94 + 2;
28447            var top = Math.random() * 88 + 6;
28448            var dur = (Math.random() * 10 + 9).toFixed(1);
28449            var delay = (Math.random() * 18).toFixed(1);
28450            var rot = (Math.random() * 26 - 13).toFixed(1);
28451            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28452            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';
28453            container.appendChild(el);
28454          })(i);
28455        }
28456      })();
28457    })();
28458  </script>
28459  <script nonce="{{ csp_nonce }}">
28460  (function(){
28461    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'}];
28462    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);});}
28463    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28464    function init(){
28465      var btn=document.getElementById('settings-btn');if(!btn)return;
28466      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28467      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>';
28468      document.body.appendChild(m);
28469      var g=document.getElementById('scheme-grid');
28470      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);});
28471      var cl=document.getElementById('settings-close');
28472      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);});})();
28473      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');});
28474      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28475      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28476    }
28477    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28478  }());
28479  </script>
28480  <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>
28481</body>
28482</html>
28483"##,
28484    ext = "html"
28485)]
28486struct HistoryTemplate {
28487    version: &'static str,
28488    entries: Vec<HistoryEntryRow>,
28489    total_scans: usize,
28490    linked_count: usize,
28491    browse_error: Option<String>,
28492    watched_dirs: Vec<String>,
28493    csp_nonce: String,
28494    server_mode: bool,
28495}
28496
28497// ── CompareSelectTemplate ──────────────────────────────────────────────────────
28498
28499#[derive(Template)]
28500#[template(
28501    source = r##"
28502<!doctype html>
28503<html lang="en">
28504<head>
28505  <meta charset="utf-8">
28506  <meta name="viewport" content="width=device-width, initial-scale=1">
28507  <title>OxideSLOC | Compare Scans</title>
28508  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28509  <style nonce="{{ csp_nonce }}">
28510    :root {
28511      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28512      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28513      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28514      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28515      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28516    }
28517    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28518    *{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;}
28519    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28520    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28521    .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);}
28522    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28523    .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));}
28524    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28525    .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;}
28526    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28527    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28528    @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; } }
28529    .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;}
28530    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28531    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28532    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28533    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28534    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28535    .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;}
28536    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28537    .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);}
28538    .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;}
28539    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28540    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28541    .settings-modal-body{padding:14px 16px 16px;}
28542    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28543    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28544    .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;}
28545    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28546    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28547    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28548    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28549    .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;}
28550    .tz-select:focus{border-color:var(--oxide);}
28551    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28552    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28553    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28554    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28555    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28556    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28557    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28558    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28559    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28560    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28561    .per-page-label{font-size:13px;color:var(--muted);}
28562    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;}
28563    .filter-input{min-width:180px;cursor:text;}
28564    .table-wrap{width:100%;overflow-x:auto;}
28565    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28566    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;}
28567    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28568    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28569    #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;}
28570    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28571    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28572    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28573    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28574    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28575    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28576    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28577    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28578    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28579    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28580    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28581    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28582    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28583    tr:last-child td{border-bottom:none;}
28584    tr.selected td{background:var(--sel-bg);}
28585    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28586    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28587    tr{cursor:pointer;}
28588    tr.row-locked{opacity:.35;cursor:not-allowed;}
28589    tr.row-locked td{pointer-events:none;}
28590    .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;}
28591    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28592    .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;}
28593    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28594    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28595    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28596    .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);}
28597    .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);}
28598    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28599    .metric-num{font-weight:700;color:var(--text);}
28600    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28601    .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;}
28602    .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;}
28603    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28604    .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;}
28605    .btn:hover{background:var(--line);}
28606    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28607    .btn.primary:hover{opacity:.9;}
28608    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28609    .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;}
28610    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28611    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28612    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28613    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28614    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28615    .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;}
28616    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28617    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28618    .watched-chip-rm:hover{color:var(--oxide);}
28619    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28620    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28621    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28622    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28623    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28624    .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;}
28625    .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;}
28626    .btn-back:hover{background:var(--line);}
28627    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28628    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28629    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28630    .pagination-info{font-size:13px;color:var(--muted);}
28631    .pagination-btns{display:flex;gap:6px;}
28632    .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;}
28633    .pg-btn:hover:not(:disabled){background:var(--line);}
28634    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28635    .pg-btn:disabled{opacity:.35;cursor:default;}
28636    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28637    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28638    .site-footer a{color:var(--muted);}
28639    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28640    .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;}
28641    .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;}
28642    .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;}
28643    @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));}}
28644    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28645    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28646    .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);}
28647    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28648    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28649    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28650    .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);}
28651    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28652    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28653    .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;}
28654    .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;}
28655    .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%;}
28656    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28657    .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;}
28658    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28659    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28660    .hidden{display:none!important;}
28661    .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%;}
28662    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28663    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28664    .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;}
28665    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28666    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28667    .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;}
28668    .scope-option:hover{background:var(--line);}
28669    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28670    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28671    .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;}
28672    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28673    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28674    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28675    .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;}
28676  </style>
28677</head>
28678<body>
28679  <div class="background-watermarks" aria-hidden="true">
28680    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28681    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28682    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28683    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28684    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28685    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28686  </div>
28687  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28688  <div class="top-nav">
28689    <div class="top-nav-inner">
28690      <a class="brand" href="/">
28691        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28692        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28693      </a>
28694      <div class="nav-right">
28695        <a class="nav-pill" href="/">Home</a>
28696        <div class="nav-dropdown">
28697          <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>
28698          <div class="nav-dropdown-menu">
28699            <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>
28700          </div>
28701        </div>
28702        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28703        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28704        <div class="nav-dropdown">
28705          <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>
28706          <div class="nav-dropdown-menu">
28707            <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>
28708          </div>
28709        </div>
28710        <div class="server-status-wrap" id="server-status-wrap">
28711          <div class="nav-pill server-online-pill" id="server-status-pill">
28712            <span class="status-dot" id="status-dot"></span>
28713            <span id="server-status-label">Server</span>
28714            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28715          </div>
28716          <div class="server-status-tip">
28717            OxideSLOC is running — accessible on your network.
28718            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28719          </div>
28720        </div>
28721        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28722          <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>
28723        </button>
28724        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28725          <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>
28726          <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>
28727        </button>
28728      </div>
28729    </div>
28730  </div>
28731
28732  <div class="page">
28733    <div class="watched-bar">
28734      <div class="watched-bar-left">
28735        <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>
28736        <span class="watched-label">Watched Folders</span>
28737        <div class="watched-chips">
28738          {% if server_mode %}
28739          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28740          {% else %}
28741          {% for dir in watched_dirs %}
28742          <span class="watched-chip">
28743            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28744            <form method="POST" action="/watched-dirs/remove" style="display:contents">
28745              <input type="hidden" name="folder_path" value="{{ dir }}">
28746              <input type="hidden" name="redirect_to" value="/compare-scans">
28747              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
28748            </form>
28749          </span>
28750          {% endfor %}
28751          {% if watched_dirs.is_empty() %}
28752          <span class="watched-none">No folders watched — click Choose to add one</span>
28753          {% endif %}
28754          {% endif %}
28755        </div>
28756      </div>
28757      {% if !server_mode %}
28758      <div class="watched-bar-right">
28759        <button type="button" class="btn" id="add-watched-btn">
28760          <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>
28761          Choose
28762        </button>
28763        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28764          <input type="hidden" name="redirect_to" value="/compare-scans">
28765          <button type="submit" class="btn">&#8635; Refresh</button>
28766        </form>
28767      </div>
28768      {% endif %}
28769    </div>
28770    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28771      <div class="scan-overlay-card">
28772        <div class="scan-spinner"></div>
28773        <div class="scan-overlay-text">Scanning folder…</div>
28774        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28775      </div>
28776    </div>
28777    <style>
28778    .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);}
28779    .scan-overlay.active{display:flex;}
28780    .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;}
28781    .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;}
28782    @keyframes scanSpin{to{transform:rotate(360deg);}}
28783    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28784    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28785    </style>
28786    {% if total_scans > 0 %}
28787    <div class="summary-strip">
28788      <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>
28789      <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>
28790      <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>
28791      <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>
28792    </div>
28793    {% endif %}
28794    <section class="panel">
28795      <div class="panel-header">
28796        <div>
28797          <h1>Compare Scans</h1>
28798          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28799        </div>
28800        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28801          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28802            <button class="btn primary" id="compare-btn" disabled>
28803              <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>
28804              Compare <span class="sel-count" id="sel-count">0</span> Selected
28805            </button>
28806          </div>
28807        </div>
28808      </div>
28809
28810      {% if entries.is_empty() %}
28811      <div class="empty-state">
28812        <strong>No scans yet</strong>
28813        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.
28814      </div>
28815      {% else %}
28816      <div class="filter-row">
28817        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
28818        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28819        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
28820      </div>
28821      <div class="scope-panel hidden" id="scope-panel">
28822        <div class="scope-panel-label">
28823          <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>
28824          Compare scope — choose what to include
28825        </div>
28826        <div class="scope-options" id="scope-options"></div>
28827      </div>
28828      {% if total_scans > 0 %}
28829      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28830        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28831          <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>
28832          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28833        </div>
28834      </div>
28835      {% endif %}
28836      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28837        <span class="compare-all-label">
28838          <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>
28839          Quick Compare All
28840        </span>
28841      </div>
28842      <div class="table-wrap">
28843        <table id="compare-table">
28844          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28845          <thead>
28846            <tr id="compare-thead">
28847              <th><div class="col-resize-handle"></div></th>
28848              <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>
28849              <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>
28850              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28851              <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>
28852              <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>
28853              <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>
28854              <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>
28855              <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>
28856              <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>
28857              <th>Submodules<div class="col-resize-handle"></div></th>
28858            </tr>
28859          </thead>
28860          <tbody id="compare-tbody">
28861            {% for entry in entries %}
28862            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28863                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28864                data-project="{{ entry.project_label }}"
28865                data-files="{{ entry.files_analyzed }}"
28866                data-code="{{ entry.code_lines }}"
28867                data-comments="{{ entry.comment_lines }}"
28868                data-blank="{{ entry.blank_lines }}"
28869                data-branch="{{ entry.git_branch }}"
28870                data-commit="{{ entry.git_commit }}"
28871                data-submodules="{{ entry.submodule_names_csv }}">
28872              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28873              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28874              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28875              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28876              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28877              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28878              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28879              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28880              <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>
28881              <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>
28882              <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>
28883            </tr>
28884            {% endfor %}
28885          </tbody>
28886        </table>
28887      </div>
28888      <div class="pagination">
28889        <span class="pagination-info" id="pagination-info"></span>
28890        <div class="pagination-btns" id="pagination-btns"></div>
28891        <div class="flex-row">
28892          <span class="per-page-label">Show</span>
28893          <select class="per-page" id="per-page-sel">
28894            <option value="10">10 per page</option>
28895            <option value="25" selected>25 per page</option>
28896            <option value="50">50 per page</option>
28897            <option value="100">100 per page</option>
28898          </select>
28899          <span class="per-page-label" id="page-range-label"></span>
28900        </div>
28901      </div>
28902      {% endif %}
28903    </section>
28904  </div>
28905
28906  <footer class="site-footer">
28907    local code analysis - metrics, history and reports
28908    &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>
28909    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28910    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28911    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28912    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28913  </footer>
28914
28915  <script nonce="{{ csp_nonce }}">
28916    (function () {
28917      // ── Theme ──────────────────────────────────────────────────────────────
28918      var storageKey = 'oxide-sloc-theme';
28919      var body = document.body;
28920      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28921      var toggle = document.getElementById('theme-toggle');
28922      if (toggle) toggle.addEventListener('click', function () {
28923        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28924        body.classList.toggle('dark-theme', next === 'dark');
28925        try { localStorage.setItem(storageKey, next); } catch(e) {}
28926      });
28927
28928      // ── State ─────────────────────────────────────────────────────────────
28929      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28930      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28931      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28932      window._allCompareRows = allRows;
28933
28934      // ── Stat chips ────────────────────────────────────────────────────────
28935      (function() {
28936        var projects = {}, latestTs = '', latestRow = null;
28937        allRows.forEach(function(r) {
28938          var p = r.dataset.project || ''; if (p) projects[p] = true;
28939          var ts = r.dataset.timestamp || '';
28940          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28941        });
28942        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();}
28943        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>':'');}
28944        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28945        if (latestRow) {
28946          setChipVal('agg-code', latestRow.dataset.code);
28947          setChipVal('agg-files', latestRow.dataset.files);
28948        }
28949        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(); });
28950      })();
28951
28952      // ── Branch filter population ──────────────────────────────────────────
28953      (function() {
28954        var branches = {};
28955        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28956        var sel = document.getElementById('branch-filter');
28957        if (sel) Object.keys(branches).sort().forEach(function(b) {
28958          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28959        });
28960      })();
28961
28962      // ── Filter ────────────────────────────────────────────────────────────
28963      function getFilteredRows() {
28964        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28965        var branch = ((document.getElementById('branch-filter') || {}).value || '');
28966        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28967          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28968          if (branch && (r.dataset.branch || '') !== branch) return false;
28969          return true;
28970        });
28971      }
28972
28973      // ── Pagination ────────────────────────────────────────────────────────
28974      function renderPage() {
28975        var filtered = getFilteredRows();
28976        var total = filtered.length;
28977        var totalPages = Math.max(1, Math.ceil(total / perPage));
28978        currentPage = Math.min(currentPage, totalPages);
28979        var start = (currentPage - 1) * perPage;
28980        var end = Math.min(start + perPage, total);
28981        var shown = {};
28982        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28983        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28984          r.style.display = shown[r.dataset.run] ? '' : 'none';
28985        });
28986        var rl = document.getElementById('page-range-label');
28987        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28988        var info = document.getElementById('pagination-info');
28989        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28990        var btns = document.getElementById('pagination-btns');
28991        if (!btns) return;
28992        btns.innerHTML = '';
28993        function makeBtn(lbl, pg, active, disabled) {
28994          var b = document.createElement('button');
28995          b.className = 'pg-btn' + (active ? ' active' : '');
28996          b.textContent = lbl; b.disabled = disabled;
28997          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28998          return b;
28999        }
29000        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
29001        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29002        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
29003        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
29004      }
29005
29006      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
29007      window.applyFilters = function() { currentPage = 1; renderPage(); };
29008
29009      // ── Sorting ───────────────────────────────────────────────────────────
29010      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
29011      function doSort(col, type, order) {
29012        var tbody = document.getElementById('compare-tbody');
29013        if (!tbody) return;
29014        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
29015        rows.sort(function(a, b) {
29016          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
29017          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
29018          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
29019          return va < vb ? 1 : va > vb ? -1 : 0;
29020        });
29021        rows.forEach(function(r) { tbody.appendChild(r); });
29022        currentPage = 1; renderPage();
29023      }
29024      sortHeaders.forEach(function(th) {
29025        th.addEventListener('click', function(e) {
29026          if (e.target.classList.contains('col-resize-handle')) return;
29027          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
29028          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29029          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29030          th.classList.add('sort-' + sortOrder);
29031          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29032          doSort(col, type, sortOrder);
29033        });
29034      });
29035
29036      // Apply default sort (timestamp desc) on initial load
29037      (function() {
29038        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
29039        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
29040      })();
29041
29042      // ── Column resize ─────────────────────────────────────────────────────
29043      (function() {
29044        var table = document.getElementById('compare-table');
29045        if (!table) return;
29046        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29047        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
29048        ths.forEach(function(th, i) {
29049          var handle = th.querySelector('.col-resize-handle');
29050          if (!handle || !cols[i]) return;
29051          var startX, startW;
29052          handle.addEventListener('mousedown', function(e) {
29053            e.stopPropagation(); e.preventDefault();
29054            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
29055            handle.classList.add('dragging');
29056            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
29057            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29058            document.addEventListener('mousemove', onMove);
29059            document.addEventListener('mouseup', onUp);
29060          });
29061        });
29062      })();
29063
29064      // ── Full-commit hover tooltip ─────────────────────────────────────────
29065      // The commit chips live inside an overflow:auto table wrapper, which would
29066      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
29067      // (escaping the scroll container) and follow the cursor. Event delegation
29068      // keeps it working after pagination/sorting re-renders the rows.
29069      (function() {
29070        var tip = document.createElement('div');
29071        tip.className = 'commit-tip';
29072        tip.setAttribute('role', 'tooltip');
29073        document.body.appendChild(tip);
29074        var shown = false;
29075        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
29076        function place(e) {
29077          var pad = 14, r = tip.getBoundingClientRect();
29078          var x = e.clientX + pad, y = e.clientY + pad;
29079          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
29080          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
29081          tip.style.left = x + 'px'; tip.style.top = y + 'px';
29082        }
29083        function hide() { tip.style.display = 'none'; shown = false; }
29084        document.addEventListener('mouseover', function(e) {
29085          var chip = chipFrom(e.target);
29086          if (!chip) return;
29087          var full = chip.getAttribute('data-full-commit');
29088          if (!full) return;
29089          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
29090        });
29091        document.addEventListener('mousemove', function(e) {
29092          if (!shown) return;
29093          if (chipFrom(e.target)) place(e); else hide();
29094        });
29095        document.addEventListener('mouseout', function(e) {
29096          if (chipFrom(e.target)) hide();
29097        });
29098      })();
29099
29100      // ── Reset view ────────────────────────────────────────────────────────
29101      window.resetView = function() {
29102        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
29103        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
29104        sortCol = null; sortOrder = 'asc';
29105        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29106        var tbody = document.getElementById('compare-tbody');
29107        if (tbody) {
29108          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
29109          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
29110          rows.forEach(function(r) { tbody.appendChild(r); });
29111        }
29112        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
29113        var table = document.getElementById('compare-table');
29114        currentPage = 1; renderPage();
29115        currentPage = 1; renderPage();
29116      };
29117
29118      renderPage();
29119      buildCompareAllBar();
29120
29121      // ── Row selection state ───────────────────────────────────────────────
29122      var selected = [];
29123      var lockedProject = null; // project label of first selected scan
29124
29125      function updateCompareBtn() {
29126        var btn = document.getElementById('compare-btn');
29127        var cnt = document.getElementById('sel-count');
29128        if (!btn) return;
29129        btn.disabled = selected.length < 2;
29130        if (cnt) cnt.textContent = selected.length;
29131      }
29132
29133      function applyProjectLock() {
29134        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29135        allRows.forEach(function(r) {
29136          if (lockedProject === null) {
29137            r.classList.remove('row-locked');
29138          } else {
29139            var proj = r.dataset.project || '';
29140            if (proj !== lockedProject) {
29141              r.classList.add('row-locked');
29142            } else {
29143              r.classList.remove('row-locked');
29144            }
29145          }
29146        });
29147      }
29148
29149      function toggleRow(row) {
29150        if (row.classList.contains('row-locked')) return;
29151        var vid = row.dataset.vid || row.dataset.run;
29152        var idx = selected.indexOf(vid);
29153        if (idx >= 0) {
29154          selected.splice(idx, 1);
29155          row.classList.remove('selected');
29156          var b = document.getElementById('badge-' + vid);
29157          if (b) b.textContent = '';
29158          // Release project lock if nothing selected
29159          if (selected.length === 0) lockedProject = null;
29160        } else {
29161          // Set project lock on first selection
29162          if (selected.length === 0) lockedProject = row.dataset.project || null;
29163          selected.push(vid);
29164          row.classList.add('selected');
29165        }
29166        selected.forEach(function(v, i) {
29167          var b = document.getElementById('badge-' + v);
29168          if (b) b.textContent = i + 1;
29169        });
29170        applyProjectLock();
29171        updateCompareBtn();
29172        buildScopePanel();
29173      }
29174
29175      // ── Compare-All bar ───────────────────────────────────────────────────
29176      function buildCompareAllBar() {
29177        var bar = document.getElementById('compare-all-bar');
29178        if (!bar) return;
29179        // Group all rows by project label.
29180        var groups = {};
29181        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29182        // Use all rows from the source data (not just visible).
29183        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
29184        // We need ALL rows across all pages, not just the rendered ones.
29185        // Use the underlying allRows array that the pagination JS also uses.
29186        var sourceRows = window._allCompareRows || allRowsAll;
29187        sourceRows.forEach(function(r) {
29188          var proj = r.dataset.project || '';
29189          var vid = r.dataset.vid || r.dataset.run || '';
29190          if (!proj || !vid) return;
29191          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
29192          groups[proj].ids.push(vid);
29193          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
29194        });
29195        // Build buttons for each project with >= 2 scans.
29196        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
29197        if (!keys.length) { bar.style.display = 'none'; return; }
29198        bar.style.display = 'flex';
29199        // Remove old buttons (keep label).
29200        var oldBtns = bar.querySelectorAll('.compare-all-btn');
29201        oldBtns.forEach(function(b) { b.remove(); });
29202        keys.sort();
29203        keys.forEach(function(proj) {
29204          var g = groups[proj];
29205          var btn = document.createElement('button');
29206          btn.className = 'compare-all-btn';
29207          btn.type = 'button';
29208          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
29209          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
29210          btn.addEventListener('click', function() {
29211            // Sort ids by timestamp (ascending).
29212            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
29213            pairs.sort(function(a, b) { return a.ts - b.ts; });
29214            var sorted = pairs.map(function(p) { return p.id; });
29215            if (sorted.length === 2) {
29216              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
29217            } else {
29218              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
29219            }
29220          });
29221          bar.appendChild(btn);
29222        });
29223      }
29224
29225      // ── Scope panel ───────────────────────────────────────────────────────
29226      var selectedScope = 'all';
29227
29228      function buildScopePanel() {
29229        var panel = document.getElementById('scope-panel');
29230        var opts = document.getElementById('scope-options');
29231        if (!panel || !opts) return;
29232        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29233
29234        // Collect union of submodules from all selected rows.
29235        var allSubs = {};
29236        selected.forEach(function(vid) {
29237          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
29238          if (!row) return;
29239          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
29240        });
29241        var subList = Object.keys(allSubs).sort();
29242        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
29243
29244        panel.classList.remove('hidden');
29245        opts.innerHTML = '';
29246
29247        function makeOption(value, label, title) {
29248          var div = document.createElement('div');
29249          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
29250          div.dataset.scopeValue = value;
29251          if (title) div.title = title;
29252          var radio = document.createElement('span');
29253          radio.className = 'scope-option-radio';
29254          var lbl = document.createElement('span');
29255          lbl.textContent = label;
29256          div.appendChild(radio);
29257          div.appendChild(lbl);
29258          div.addEventListener('click', function() {
29259            selectedScope = value;
29260            opts.querySelectorAll('.scope-option').forEach(function(o) {
29261              o.classList.toggle('selected', o.dataset.scopeValue === value);
29262            });
29263          });
29264          return div;
29265        }
29266
29267        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
29268        var sep = document.createElement('span');
29269        sep.className = 'scope-option-sep';
29270        opts.appendChild(sep);
29271        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
29272        subList.forEach(function(s) {
29273          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
29274        });
29275      }
29276
29277      function doCompare() {
29278        if (selected.length < 2) return;
29279        if (selected.length === 2) {
29280          // Two-scan delta (existing flow with scope support).
29281          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
29282          if (selectedScope === 'super') url += '&scope=super';
29283          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29284          window.location.href = url;
29285        } else {
29286          // Multi-scan timeline (N >= 3) — pass scope params too.
29287          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
29288          if (selectedScope === 'super') url += '&scope=super';
29289          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
29290          window.location.href = url;
29291        }
29292      }
29293
29294      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
29295      var cbtn = document.getElementById('compare-btn');
29296      if (cbtn) cbtn.addEventListener('click', doCompare);
29297      var pfEl = document.getElementById('project-filter');
29298      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
29299      var bfEl = document.getElementById('branch-filter');
29300      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
29301      var rvBtn = document.getElementById('reset-view-btn');
29302      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
29303      var ppSel = document.getElementById('per-page-sel');
29304      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
29305
29306      var cmpTbody = document.getElementById('compare-tbody');
29307      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
29308        var row = e.target.closest('.compare-row');
29309        if (row) toggleRow(row);
29310      });
29311
29312      (function randomizeWatermarks() {
29313        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29314        if (!wms.length) return;
29315        var placed = [];
29316        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;}
29317        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];}
29318        var half=Math.floor(wms.length/2);
29319        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;});
29320      })();
29321
29322      (function spawnCodeParticles() {
29323        var container = document.getElementById('code-particles');
29324        if (!container) return;
29325        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'];
29326        for (var i = 0; i < 38; i++) {
29327          (function(idx) {
29328            var el = document.createElement('span');
29329            el.className = 'code-particle';
29330            el.textContent = snippets[idx % snippets.length];
29331            var left = Math.random() * 94 + 2;
29332            var top = Math.random() * 88 + 6;
29333            var dur = (Math.random() * 10 + 9).toFixed(1);
29334            var delay = (Math.random() * 18).toFixed(1);
29335            var rot = (Math.random() * 26 - 13).toFixed(1);
29336            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29337            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';
29338            container.appendChild(el);
29339          })(i);
29340        }
29341      })();
29342
29343      // ── Watched folder picker ─────────────────────────────────────────────
29344      (function(){
29345        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');};
29346        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);
29347      })();
29348      (function() {
29349        var btn = document.getElementById('add-watched-btn');
29350        if (!btn) return;
29351        btn.addEventListener('click', function() {
29352          fetch('/pick-directory?kind=reports')
29353            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29354            .then(function(data) {
29355              if (!data.cancelled && data.selected_path) {
29356                var form = document.createElement('form');
29357                form.method = 'POST';
29358                form.action = '/watched-dirs/add';
29359                var ri = document.createElement('input');
29360                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29361                var fi = document.createElement('input');
29362                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29363                form.appendChild(ri); form.appendChild(fi);
29364                document.body.appendChild(form);
29365                if (window.__scanOverlay) window.__scanOverlay();
29366                form.submit();
29367              }
29368            })
29369            .catch(function(e) { alert('Could not open folder picker: ' + e); });
29370        });
29371      })();
29372
29373      // ── Submodule chip truncation ─────────────────────────────────────────
29374      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29375        var chips = cell.querySelectorAll('.submod-chip');
29376        var MAX = 4;
29377        if (chips.length <= MAX) return;
29378        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29379        var badge = document.createElement('span');
29380        badge.className = 'submod-overflow-badge';
29381        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29382        badge.textContent = '+' + (chips.length - MAX) + ' more';
29383        cell.appendChild(badge);
29384        cell.style.maxHeight = 'none';
29385      });
29386    })();
29387  </script>
29388  <script nonce="{{ csp_nonce }}">
29389  (function(){
29390    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'}];
29391    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);});}
29392    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29393    function init(){
29394      var btn=document.getElementById('settings-btn');if(!btn)return;
29395      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29396      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>';
29397      document.body.appendChild(m);
29398      var g=document.getElementById('scheme-grid');
29399      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);});
29400      var cl=document.getElementById('settings-close');
29401      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);});})();
29402      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');});
29403      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29404      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29405    }
29406    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29407  }());
29408  </script>
29409  <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]';
29410  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;}
29411  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>
29412</body>
29413</html>
29414"##,
29415    ext = "html"
29416)]
29417struct CompareSelectTemplate {
29418    version: &'static str,
29419    entries: Vec<HistoryEntryRow>,
29420    total_scans: usize,
29421    watched_dirs: Vec<String>,
29422    csp_nonce: String,
29423    server_mode: bool,
29424}
29425
29426// ── CompareTemplate ────────────────────────────────────────────────────────────
29427
29428#[derive(Template)]
29429#[template(
29430    source = r##"
29431<!doctype html>
29432<html lang="en">
29433<head>
29434  <meta charset="utf-8">
29435  <meta name="viewport" content="width=device-width, initial-scale=1">
29436  <title>OxideSLOC | Scan Delta</title>
29437  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29438  <style nonce="{{ csp_nonce }}">
29439    :root {
29440      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29441      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29442      --nav:#283790; --nav-2:#013e6b;
29443      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29444      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29445      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29446    }
29447    body.dark-theme {
29448      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29449      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29450    }
29451    *{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;}
29452    .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);}
29453    .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;}
29454    .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));}
29455    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29456    .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;}
29457    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29458    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29459    @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; } }
29460    .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;}
29461    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29462    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29463    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29464    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29465    .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;}
29466    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29467    .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);}
29468    .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;}
29469    .settings-close:hover{color:var(--text);background:var(--surface-2);}
29470    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29471    .settings-modal-body{padding:14px 16px 16px;}
29472    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29473    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29474    .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;}
29475    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29476    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29477    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29478    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29479    .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;}
29480    .tz-select:focus{border-color:var(--oxide);}
29481    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29482    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29483    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29484    .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;}
29485    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29486    .hero-body{display:block;}
29487    .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;}
29488    .btn-back:hover{background:var(--line);}
29489    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29490    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29491    .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;}
29492    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29493    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;}
29494    .muted{color:var(--muted);font-size:14px;}
29495    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29496    .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;}
29497    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29498    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29499    .vpill-arrow{font-size:20px;color:var(--muted);}
29500    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29501    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29502    .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;}
29503    .delta-card.delta-card-wide{padding:22px 24px;}
29504    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29505    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29506    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29507    .delta-card-from{font-size:15px;color:var(--muted);}
29508    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29509    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29510    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29511    .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%;}
29512    .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;}
29513    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29514    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29515    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29516    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29517    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29518    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29519    .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;}
29520    .meta-card-commit:hover{color:var(--oxide);}
29521    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29522    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29523    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29524    .meta-value{color:var(--text);font-size:13px;}
29525    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29526    .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;}
29527    .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);}
29528    .delta-card:hover .dc-tip{display:block;}
29529    .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;}
29530    .export-btn:hover{background:var(--line);}
29531    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29532    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29533    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29534    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29535    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29536    .delta-card-change.zero{color:var(--muted);background:transparent;}
29537    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29538    .delta-card-pct.pos{color:var(--pos);}
29539    .delta-card-pct.neg{color:var(--neg);}
29540    .delta-card-pct.zero{color:var(--muted);}
29541    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29542    .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;}
29543    .insight-card.insight-flag{border-color:var(--oxide);}
29544    .insight-card:hover .dc-tip{display:block;}
29545    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29546    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29547    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29548    .insight-label.flag{color:var(--oxide);}
29549    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29550    .insight-val.pos{color:var(--pos);}
29551    .insight-val.neg{color:var(--neg);}
29552    .insight-val.high{color:#c0392a;}
29553    .insight-val.med{color:#926000;}
29554    .insight-val.low{color:var(--pos);}
29555    body.dark-theme .insight-val.high{color:#ff6b6b;}
29556    body.dark-theme .insight-val.med{color:#f0c060;}
29557    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29558    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29559    .fc-row{display:flex;align-items:center;gap:8px;}
29560    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29561    .fc-label{color:var(--muted);}
29562    .fc-modified .fc-count{color:#926000;}
29563    .fc-added .fc-count{color:var(--pos);}
29564    .fc-removed .fc-count{color:var(--neg);}
29565    .fc-unchanged .fc-count{color:var(--muted);}
29566    .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29567    .fc-total .fc-count{color:var(--text);}
29568    .fc-total .fc-label{font-weight:700;}
29569    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29570    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29571    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29572    .chip.modified{background:#fff2d8;color:#926000;}
29573    .chip.added{background:#e8f5ed;color:#1a8f47;}
29574    .chip.removed{background:#fdeaea;color:#b33b3b;}
29575    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29576    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29577    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29578    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29579    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29580    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29581    .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;}
29582    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29583    .tab-btn:hover:not(.active){background:var(--line);}
29584    .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;}
29585    .btn-reset:hover{background:var(--line);}
29586    .table-wrap{width:100%;overflow-x:auto;}
29587    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29588    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);}
29589    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29590    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29591    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29592    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29593    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29594    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29595    tr:last-child td{border-bottom:none;}
29596    tr:hover td{background:var(--surface-2);}
29597    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29598    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29599    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29600    /* Fixed layout: column widths come from the colgroup, not from scanning every
29601       row. With auto layout a large file matrix forces the browser to re-measure
29602       all cells on each reflow, which freezes the page during sort/resize. */
29603    #delta-table{table-layout:fixed;}
29604    #delta-table col:nth-child(1){width:32%;}
29605    #delta-table col:nth-child(2){width:11%;}
29606    #delta-table col:nth-child(3){width:11%;}
29607    #delta-table col:nth-child(4){width:16%;}
29608    #delta-table col:nth-child(5){width:10%;}
29609    #delta-table col:nth-child(6){width:10%;}
29610    #delta-table col:nth-child(7){width:10%;}
29611    tr.row-added td{background:rgba(26,143,71,0.04);}
29612    tr.row-removed td{background:rgba(179,59,59,0.06);}
29613    tr.row-modified td{background:rgba(146,96,0,0.04);}
29614    tr.row-unchanged td{color:var(--muted);}
29615    tr.row-unchanged .status-badge{opacity:.65;}
29616    .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;}
29617    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29618    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29619    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29620    .status-badge.modified{background:#fff2d8;color:#926000;}
29621    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29622    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29623    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29624    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29625    .delta-val{font-weight:700;}
29626    .delta-val.pos{color:var(--pos);}
29627    .delta-val.neg{color:var(--neg);}
29628    .delta-val.zero{color:var(--muted);}
29629    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29630    .from-to strong{color:var(--text);font-weight:700;}
29631    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29632    .from-to .ft-absent{color:var(--muted);font-weight:600;}
29633    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29634    .site-footer a{color:var(--muted);}
29635    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;}
29636    body.pdf-mode{background:#fff!important;}
29637    body.pdf-mode .page{padding:4px 6px 4px!important;}
29638    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29639    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29640    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29641    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29642    .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;}
29643    .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;}
29644    .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;}
29645    @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));}}
29646    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29647    .path-link:hover{color:var(--oxide-2);}
29648    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29649    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29650    a.vpill-id:hover{color:var(--oxide);}
29651    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29652    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29653    .pagination-info{font-size:13px;color:var(--muted);}
29654    .pagination-btns{display:flex;gap:6px;}
29655    .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;}
29656    .pg-btn:hover:not(:disabled){background:var(--line);}
29657    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29658    .pg-btn:disabled{opacity:.35;cursor:default;}
29659    .per-page-label{font-size:13px;color:var(--muted);}
29660    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;}
29661    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29662    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29663    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29664    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29665    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29666    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29667    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29668    .tab-btn.tab-unchanged{color:var(--muted);}
29669    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29670    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29671    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29672    .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;}
29673    .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;}
29674    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29675    .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;}
29676    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29677    .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;}
29678    .submod-scope-btn:hover{background:var(--line);}
29679    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29680    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29681    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29682    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29683    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29684    body.dark-theme .ic-card{background:var(--surface-2);}
29685    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29686    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29687    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29688    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29689    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29690    .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);}
29691    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29692    .ic-card-h2-row .ic-card-h2{margin:0;}
29693    .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;}
29694    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29695    .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;}
29696    .ic-svg-modal-ov.open{display:flex;}
29697    .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);}
29698    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29699    .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);}
29700    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29701    .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;}
29702    .ic-svg-modal-close:hover{background:var(--line);}
29703    .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;}
29704    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29705    .chart-metric-btn:hover:not(.active){background:var(--line);}
29706    .chart-wrap{width:100%;overflow-x:auto;}
29707    #cmp-tl-svg{display:block;width:100%;}
29708    .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);}
29709    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29710    #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;}
29711  </style>
29712</head>
29713<body>
29714  {{ loading_overlay|safe }}
29715  <div class="background-watermarks" aria-hidden="true">
29716    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29717    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29718    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29719    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29720    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29721    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29722  </div>
29723  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29724  <div class="top-nav">
29725    <div class="top-nav-inner">
29726      <a class="brand" href="/">
29727        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29728        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29729      </a>
29730      <div class="nav-right">
29731        <a class="nav-pill" href="/">Home</a>
29732        <div class="nav-dropdown">
29733          <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>
29734          <div class="nav-dropdown-menu">
29735            <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>
29736          </div>
29737        </div>
29738        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29739        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29740        <div class="nav-dropdown">
29741          <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>
29742          <div class="nav-dropdown-menu">
29743            <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>
29744          </div>
29745        </div>
29746        <div class="server-status-wrap" id="server-status-wrap">
29747          <div class="nav-pill server-online-pill" id="server-status-pill">
29748            <span class="status-dot" id="status-dot"></span>
29749            <span id="server-status-label">Server</span>
29750            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29751          </div>
29752          <div class="server-status-tip">
29753            OxideSLOC is running — accessible on your network.
29754            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29755          </div>
29756        </div>
29757        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29758          <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>
29759        </button>
29760        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29761          <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>
29762          <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>
29763        </button>
29764      </div>
29765    </div>
29766  </div>
29767
29768  <div class="page">
29769    <section class="hero">
29770      <div class="hero-header">
29771        <div>
29772          <h1 class="delta-title">Scan Delta</h1>
29773          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29774          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29775            {% if let Some(sub) = active_submodule %}
29776            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29777            {% else if super_scope_active %}
29778            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29779            {% else %}
29780            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29781            {% endif %}
29782            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29783          </div>
29784        </div>
29785        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29786          <a class="btn-back" href="/compare-scans">
29787            <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>
29788            Compare Scans
29789          </a>
29790          <div class="export-group" style="margin-top:12px;">
29791            <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>
29792            <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>
29793          </div>
29794        </div>
29795      </div>
29796      {% if has_any_submodule_data %}
29797      <div class="submod-scope-bar">
29798        <span class="submod-scope-label">
29799          <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>
29800          Scope:
29801        </span>
29802        <div class="submod-scope-divider"></div>
29803        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29804           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
29805           title="All files — super-repo and all submodules combined">Full scan</a>
29806        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29807           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
29808           title="Only files that are not part of any submodule">Super-repo only</a>
29809        {% for sub in submodule_options %}
29810        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29811           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
29812           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29813        {% endfor %}
29814      </div>
29815      {% endif %}
29816      <div class="hero-body">
29817      <div class="meta-strip">
29818        <div class="delta-card delta-card-meta">
29819          <div class="meta-card-header">
29820            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29821            <div class="meta-card-project-col">
29822              <div class="meta-card-project">{{ project_name }}</div>
29823              {% if has_any_submodule_data %}
29824              {% if let Some(sub) = active_submodule %}
29825              <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>
29826              {% else if super_scope_active %}
29827              <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>
29828              {% else %}
29829              <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>
29830              {% endif %}
29831              {% endif %}
29832            </div>
29833          </div>
29834          {% if !baseline_git_commit.is_empty() %}
29835          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29836          {% else %}
29837          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29838          {% endif %}
29839          <div class="meta-card-rows">
29840            <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>
29841            <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>
29842            <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>
29843            <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>
29844            {% if let Some(tags) = baseline_git_tags %}
29845            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29846            {% endif %}
29847          </div>
29848        </div>
29849        <div class="delta-card delta-card-meta">
29850          <div class="meta-card-header">
29851            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29852            <div class="meta-card-project-col">
29853              <div class="meta-card-project">{{ project_name }}</div>
29854              {% if has_any_submodule_data %}
29855              {% if let Some(sub) = active_submodule %}
29856              <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>
29857              {% else if super_scope_active %}
29858              <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>
29859              {% else %}
29860              <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>
29861              {% endif %}
29862              {% endif %}
29863            </div>
29864          </div>
29865          {% if !current_git_commit.is_empty() %}
29866          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29867          {% else %}
29868          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29869          {% endif %}
29870          <div class="meta-card-rows">
29871            <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>
29872            <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>
29873            <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>
29874            <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>
29875            {% if let Some(tags) = current_git_tags %}
29876            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29877            {% endif %}
29878          </div>
29879        </div>
29880      </div>
29881      <div class="delta-strip">
29882        <div class="delta-card">
29883          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29884          <div class="delta-card-label">Code lines</div>
29885          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29886          <div class="delta-card-to">{{ current_code_fmt }}</div>
29887          {% 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>
29888          {% 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>
29889          {% else %}<div class="delta-card-pct zero">±0%</div>
29890          {% endif %}
29891        </div>
29892        <div class="delta-card">
29893          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29894          <div class="delta-card-label">Files analyzed</div>
29895          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29896          <div class="delta-card-to">{{ current_files_fmt }}</div>
29897          {% 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>
29898          {% 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>
29899          {% else %}<div class="delta-card-pct zero">±0%</div>
29900          {% endif %}
29901        </div>
29902        <div class="delta-card">
29903          <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>
29904          <div class="delta-card-label">Comment lines</div>
29905          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29906          <div class="delta-card-to">{{ current_comments_fmt }}</div>
29907          {% 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>
29908          {% 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>
29909          {% else %}<div class="delta-card-pct zero">±0%</div>
29910          {% endif %}
29911        </div>
29912        {{ coverage_delta_card|safe }}
29913        <div class="delta-card delta-card-wide">
29914          <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>
29915          <div class="delta-card-label">File changes</div>
29916          <div class="file-changes-grid">
29917            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29918            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29919            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29920            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29921            <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>
29922          </div>
29923        </div>
29924      </div>
29925      <div class="insights-panel">
29926        <div class="insight-card">
29927          <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>
29928          <div class="insight-label">Lines Added</div>
29929          <div class="insight-val pos">+{{ code_lines_added }}</div>
29930          <div class="insight-sub">New or grown source lines</div>
29931        </div>
29932        <div class="insight-card">
29933          <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>
29934          <div class="insight-label">Lines Removed</div>
29935          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
29936          <div class="insight-sub">Deleted or shrunk source lines</div>
29937        </div>
29938        <div class="insight-card">
29939          <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>
29940          <div class="insight-label">Lines Modified</div>
29941          <div class="insight-val">{{ code_lines_modified }}</div>
29942          <div class="insight-sub">Code lines in modified files</div>
29943        </div>
29944        <div class="insight-card">
29945          <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>
29946          <div class="insight-label">Lines Unmodified</div>
29947          <div class="insight-val">{{ code_lines_unmodified }}</div>
29948          <div class="insight-sub">Code lines in unchanged files</div>
29949        </div>
29950        <div class="insight-card">
29951          <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29952          <div class="insight-label">Lines Total</div>
29953          <div class="insight-val">{{ code_lines_total }}</div>
29954          <div class="insight-sub">Added + removed + modified + unmodified</div>
29955        </div>
29956        <div class="insight-card">
29957          <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>
29958          <div class="insight-label">Churn Rate</div>
29959          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29960          <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>
29961        </div>
29962        {% if scope_flag %}
29963        <div class="insight-card insight-flag">
29964          <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>
29965          <div class="insight-label flag">Scope Signal</div>
29966          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29967          <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>
29968        </div>
29969        {% endif %}
29970      </div>
29971      </div>
29972    </section>
29973
29974    <section class="panel" id="inline-charts-section">
29975      <div class="panel-title">Scan Delta Charts</div>
29976      <div class="ic-grid">
29977        <div class="ic-card" style="grid-column:span 2">
29978          <div class="ic-card-h2-row">
29979            <span class="ic-card-h2">Timeline</span>
29980            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29981              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29982              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29983              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29984              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29985              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29986            </div>
29987            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
29988          </div>
29989          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29990        </div>
29991        <div class="ic-card">
29992          <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>
29993          <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>
29994          <div id="ic-c1"></div>
29995        </div>
29996        <div class="ic-card" id="ic-lang-card">
29997          <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>
29998          <div id="ic-c3"></div>
29999        </div>
30000        <div class="ic-card">
30001          <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>
30002          <div id="ic-c2"></div>
30003        </div>
30004        <div class="ic-card">
30005          <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>
30006          <div id="ic-c4"></div>
30007        </div>
30008      </div>
30009      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
30010        <div class="ic-svg-modal">
30011          <div class="ic-svg-modal-hdr">
30012            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
30013            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
30014          </div>
30015          <div id="ic-svg-modal-body"></div>
30016        </div>
30017      </div>
30018    </section>
30019
30020    <section class="panel">
30021      <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>
30022      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
30023        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
30024          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
30025          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
30026          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
30027          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
30028          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
30029        </div>
30030        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
30031          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
30032          <div class="export-group">
30033            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
30034            <button type="button" class="export-btn" id="delta-csv-btn">
30035              <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>
30036              CSV
30037            </button>
30038            <button type="button" class="export-btn" id="delta-xls-btn">
30039              <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>
30040              Excel
30041            </button>
30042          </div>
30043        </div>
30044      </div>
30045
30046      <div class="table-wrap">
30047      <table id="delta-table">
30048        <colgroup>
30049          <col>
30050          <col>
30051          <col>
30052          <col>
30053          <col>
30054          <col>
30055          <col>
30056        </colgroup>
30057        <thead>
30058          <tr id="delta-thead">
30059            <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>
30060            <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>
30061            <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>
30062            <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>
30063            <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>
30064            <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>
30065            <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>
30066          </tr>
30067        </thead>
30068        <tbody id="delta-tbody">
30069          {% for row in file_rows %}
30070          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
30071              data-path="{{ row.relative_path }}"
30072              data-language="{{ row.language }}"
30073              data-baseline-code="{{ row.baseline_code }}"
30074              data-current-code="{{ row.current_code }}"
30075              data-code-delta="{{ row.code_delta_str }}"
30076              data-comment-delta="{{ row.comment_delta_str }}"
30077              data-total-delta="{{ row.total_delta_str }}"
30078              data-orig-idx="">
30079            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
30080            <td class="hide-sm">{{ row.language }}</td>
30081            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
30082            <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>
30083            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
30084            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
30085            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
30086          </tr>
30087          {% endfor %}
30088        </tbody>
30089      </table>
30090      </div>
30091      <div class="pagination">
30092        <span class="pagination-info" id="pg-range-label"></span>
30093        <div class="pagination-btns" id="pg-btns"></div>
30094        <div class="flex-row">
30095          <span class="per-page-label">Show</span>
30096          <select class="per-page" id="per-page-sel">
30097            <option value="10">10 per page</option>
30098            <option value="25" selected>25 per page</option>
30099            <option value="50">50 per page</option>
30100            <option value="100">100 per page</option>
30101          </select>
30102        </div>
30103      </div>
30104    </section>
30105  </div>
30106
30107  <div id="ic-tt"></div>
30108
30109  <footer class="site-footer">
30110    local code analysis - metrics, history and reports
30111    &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>
30112    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30113    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30114    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30115    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
30116  </footer>
30117
30118  <script nonce="{{ csp_nonce }}">
30119    (function () {
30120      var storageKey = 'oxide-sloc-theme';
30121      var body = document.body;
30122      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
30123      var toggle = document.getElementById('theme-toggle');
30124      if (toggle) toggle.addEventListener('click', function () {
30125        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
30126        body.classList.toggle('dark-theme', next === 'dark');
30127        try { localStorage.setItem(storageKey, next); } catch(e) {}
30128      });
30129
30130      (function randomizeWatermarks() {
30131        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30132        if (!wms.length) return;
30133        var placed = [];
30134        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;}
30135        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];}
30136        var half=Math.floor(wms.length/2);
30137        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;});
30138      })();
30139
30140      (function spawnCodeParticles() {
30141        var container = document.getElementById('code-particles');
30142        if (!container) return;
30143        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'];
30144        for (var i = 0; i < 38; i++) {
30145          (function(idx) {
30146            var el = document.createElement('span');
30147            el.className = 'code-particle';
30148            el.textContent = snippets[idx % snippets.length];
30149            var left = Math.random() * 94 + 2;
30150            var top = Math.random() * 88 + 6;
30151            var dur = (Math.random() * 10 + 9).toFixed(1);
30152            var delay = (Math.random() * 18).toFixed(1);
30153            var rot = (Math.random() * 26 - 13).toFixed(1);
30154            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30155            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';
30156            container.appendChild(el);
30157          })(i);
30158        }
30159      })();
30160    })();
30161
30162    var activeStatusFilter = 'all';
30163    var deltaPerPage = 25, deltaCurrPage = 1;
30164
30165    function openFolder(path) {
30166      fetch('/open-path?path=' + encodeURIComponent(path))
30167        .then(function (r) { return r.json(); })
30168        .then(function (d) {
30169          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
30170        })
30171        .catch(function () {});
30172    }
30173
30174    // \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
30175    // The server renders every row once; we lift them into a plain-data array and
30176    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
30177    // filtering run on the array (no DOM churn) and each render rebuilds just one
30178    // page (~25 rows). This keeps every interaction O(page) instead of O(all
30179    // files): a 28k-row table previously re-touched every node on each click
30180    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
30181    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
30182
30183    function parseDeltaNum(str) {
30184      if (!str || str === '\u2014') return 0;
30185      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
30186    }
30187
30188    function captureDelta() {
30189      var tbody = document.getElementById('delta-tbody');
30190      if (!tbody) return;
30191      var rows = tbody.querySelectorAll('.delta-row');
30192      for (var i = 0; i < rows.length; i++) {
30193        var r = rows[i];
30194        DELTA.push({
30195          h: r.innerHTML,
30196          cls: r.className,
30197          path: r.getAttribute('data-path') || '',
30198          lang: r.getAttribute('data-language') || '',
30199          status: r.getAttribute('data-status') || '',
30200          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
30201          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
30202          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
30203          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
30204          td: parseDeltaNum(r.getAttribute('data-total-delta')),
30205          bcs: r.getAttribute('data-baseline-code') || '',
30206          ccs: r.getAttribute('data-current-code') || '',
30207          cds: r.getAttribute('data-code-delta') || '',
30208          cmds: r.getAttribute('data-comment-delta') || '',
30209          tds: r.getAttribute('data-total-delta') || ''
30210        });
30211      }
30212      tbody.innerHTML = '';
30213    }
30214
30215    function applyDeltaQuery() {
30216      var v = (activeStatusFilter === 'all') ? DELTA.slice()
30217        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
30218      if (sortCol) {
30219        var asc = sortOrder === 'asc';
30220        v.sort(function(a, b) {
30221          var va, vb;
30222          if (sortCol === 'path') { va = a.path; vb = b.path; }
30223          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
30224          else if (sortCol === 'status') { va = a.status; vb = b.status; }
30225          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
30226          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
30227          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
30228          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
30229          else { return 0; }
30230          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
30231          return va < vb ? 1 : va > vb ? -1 : 0;
30232        });
30233      }
30234      _deltaView = v;
30235      deltaCurrPage = 1;
30236      renderDeltaPage();
30237    }
30238
30239    function renderDeltaPage() {
30240      var total = _deltaView.length;
30241      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
30242      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
30243      if (deltaCurrPage < 1) deltaCurrPage = 1;
30244      var start = (deltaCurrPage - 1) * deltaPerPage;
30245      var end = Math.min(start + deltaPerPage, total);
30246      var tbody = document.getElementById('delta-tbody');
30247      if (tbody) {
30248        var html = '';
30249        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
30250        tbody.innerHTML = html;
30251      }
30252      var rl = document.getElementById('pg-range-label');
30253      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
30254      var btns = document.getElementById('pg-btns');
30255      if (!btns) return;
30256      btns.innerHTML = '';
30257      if (totalPages <= 1) return;
30258      function makeBtn(lbl, pg, active, disabled) {
30259        var b = document.createElement('button');
30260        b.className = 'pg-btn' + (active ? ' active' : '');
30261        b.textContent = lbl; b.disabled = disabled;
30262        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
30263        return b;
30264      }
30265      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
30266      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
30267      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
30268      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
30269    }
30270
30271    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
30272
30273    function filterRows(status, btn) {
30274      activeStatusFilter = status;
30275      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
30276        b.classList.remove('active');
30277      });
30278      if (btn) btn.classList.add('active');
30279      applyDeltaQuery();
30280    }
30281
30282    // ── Sorting ──────────────────────────────────────────────────────────────
30283    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
30284    sortHeaders.forEach(function(th) {
30285      th.addEventListener('click', function(e) {
30286        if (e.target.classList.contains('col-resize-handle')) return;
30287        var col = th.dataset.sortCol;
30288        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
30289        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30290        th.classList.add('sort-' + sortOrder);
30291        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
30292        applyDeltaQuery();
30293      });
30294    });
30295
30296    // ── Column resize ─────────────────────────────────────────────────────────
30297    (function() {
30298      var table = document.getElementById('delta-table');
30299      if (!table) return;
30300      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
30301      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
30302      ths.forEach(function(th, i) {
30303        var handle = th.querySelector('.col-resize-handle');
30304        if (!handle || !cols[i]) return;
30305        handle.addEventListener('mousedown', function(e) {
30306          e.stopPropagation(); e.preventDefault();
30307          // Lock every column to its current rendered px width and size the table
30308          // to the column total. With table-layout:fixed + width:100% the table is
30309          // pinned to the container, so widening one <col> only rebalances the rest
30310          // and the drag looks inert; pinning px widths lets the column actually
30311          // grow while the wrapper (overflow-x:auto) scrolls.
30312          var startTableW = 0;
30313          for (var k = 0; k < ths.length; k++) {
30314            if (!cols[k]) continue;
30315            var w = ths[k].getBoundingClientRect().width;
30316            cols[k].style.width = w + 'px';
30317            startTableW += w;
30318          }
30319          table.style.width = startTableW + 'px';
30320          var startX = e.clientX;
30321          var startW = ths[i].getBoundingClientRect().width;
30322          handle.classList.add('dragging');
30323          function onMove(ev) {
30324            var newW = Math.max(40, startW + ev.clientX - startX);
30325            cols[i].style.width = newW + 'px';
30326            table.style.width = (startTableW + (newW - startW)) + 'px';
30327          }
30328          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30329          document.addEventListener('mousemove', onMove);
30330          document.addEventListener('mouseup', onUp);
30331        });
30332      });
30333    })();
30334
30335    // ── Reset ─────────────────────────────────────────────────────────────────
30336    window.resetDeltaTable = function() {
30337      sortCol = null; sortOrder = 'asc';
30338      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30339      var table = document.getElementById('delta-table');
30340      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30341      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30342      activeStatusFilter = 'all';
30343      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30344      var allBtn = document.querySelector('.tab-btn');
30345      if (allBtn) allBtn.classList.add('active');
30346      applyDeltaQuery();
30347    };
30348
30349    // Compact number formatter (shared by the delta table; charts define their own locally)
30350    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();}
30351    function fmtFull(n){return Number(n).toLocaleString();}
30352
30353    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30354    function fmtFromTo() {
30355      var tbody = document.getElementById('delta-tbody');
30356      if (!tbody) return;
30357      tbody.querySelectorAll('.delta-row').forEach(function(row) {
30358        var status = row.dataset.status || '';
30359        var ft = row.querySelector('.from-to');
30360        if (!ft) return;
30361        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30362        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30363        var strongs = ft.querySelectorAll('strong');
30364        // Apply fmt() to non-absent strong values
30365        strongs.forEach(function(el) {
30366          var n = parseInt(el.textContent, 10);
30367          if (!isNaN(n)) el.textContent = fmtFull(n);
30368        });
30369        // Safety: force dash for genuinely absent sides
30370        if (status === 'added' && bv === 0) {
30371          var bs = ft.querySelector('strong:first-of-type');
30372          if (bs && bs.textContent === '0') {
30373            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30374          }
30375        }
30376        if (status === 'removed' && cv === 0) {
30377          var cs = ft.querySelector('strong:last-of-type');
30378          if (cs && cs.textContent === '0') {
30379            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30380          }
30381        }
30382      });
30383    }
30384    // Initialize: format the server-rendered rows, lift them into the data model
30385    // (which also clears the DOM), then render only the first page.
30386    fmtFromTo();
30387    captureDelta();
30388    applyDeltaQuery();
30389
30390    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30391    (function() {
30392      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30393        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30394      });
30395      var resetBtn = document.getElementById('delta-reset-btn');
30396      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30397      var csvBtn = document.getElementById('delta-csv-btn');
30398      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30399      var xlsBtn = document.getElementById('delta-xls-btn');
30400      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30401      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30402      function sdFetchUri(path) {
30403        return fetch(path).then(function(r){return r.blob();}).then(function(b){
30404          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30405        }).catch(function(){return '';});
30406      }
30407      function sdInlineImgs(html, cb) {
30408        var paths=[], seen={};
30409        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30410        if(!paths.length){cb(html);return;}
30411        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30412          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30413          .catch(function(){cb(html);});
30414      }
30415      function buildFullPageHtml(pdfMode) {
30416        if(pdfMode) document.body.classList.add('pdf-mode');
30417        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30418        renderDeltaPage();
30419        var html = document.documentElement.outerHTML;
30420        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30421        if(pdfMode) document.body.classList.remove('pdf-mode');
30422        return html;
30423      }
30424      var chartsBtn = document.getElementById('delta-charts-btn');
30425      if (chartsBtn) chartsBtn.addEventListener('click', function() {
30426        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30427        sdInlineImgs(buildFullPageHtml(false), function(html) {
30428          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30429          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30430          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30431          btn.disabled=false;btn.innerHTML=orig;
30432        });
30433      });
30434      var pageHtmlBtn = document.getElementById('page-export-html-btn');
30435      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30436        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30437        sdInlineImgs(buildFullPageHtml(false), function(html) {
30438          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30439          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30440          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30441          btn.disabled=false;btn.innerHTML=orig;
30442        });
30443      });
30444      // PDF export — clean document-style report, not a web page screenshot
30445      function buildDeltaPdfHtml() {
30446        var sd=_sd, dr=getDeltaExportRows();
30447        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30448        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+'%';}
30449        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30450        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30451        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30452        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30453        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30454        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30455        function fmtN(n){return Number(n).toLocaleString();}
30456        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30457        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>';}
30458        var lm={};
30459        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;});
30460        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30461        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30462        // The header/footer flow in normal document order (NOT position:fixed).
30463        // A fixed header repeats on every printed page in Chromium and overlaps
30464        // the content beneath it — silently swallowing the first few table rows of
30465        // pages 2+ and clipping the summary cards on page 1. Letting the header
30466        // flow once at the top and relying on the table's <thead> (which Chromium
30467        // repeats per page) keeps every row visible. `.body` keeps a small inset
30468        // so nothing bleeds to the sheet edge.
30469        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30470          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30471          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30472          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30473          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30474          '.ph-brand em{color:#c45c10;font-style:normal;}'+
30475          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30476          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30477          '.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;}'+
30478          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30479          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30480          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30481          '.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;}'+
30482          '.body{padding:12px 18px 0;}'+
30483          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30484          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30485          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30486          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30487          '.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;}'+
30488          '.meta>div{flex:1 1 0;}'+
30489          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30490          '.sec{margin-bottom:10px;}'+
30491          '.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;}'+
30492          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30493          '.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;}'+
30494          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30495          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30496          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30497          '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;}'+
30498          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30499          'tr:nth-child(even) td{background:#faf8f6;}'+
30500          '.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;}'+
30501          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30502          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30503          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30504          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30505          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30506          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30507          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30508          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30509          '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30510          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30511          '.fcc-n{font-size:18px;font-weight:900;}'+
30512          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30513        var fileRows=dchg.map(function(r){
30514          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30515          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30516            '<td style="'+ss+'">'+esc(st)+'</td>'+
30517            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30518            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30519            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30520        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30521        var more='';
30522        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('');
30523        var extraCards='';
30524        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>';}
30525        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>';}
30526        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30527          '<div class="pdf-header">'+
30528          '<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>'+
30529          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30530          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30531          '</div>'+
30532          '<div class="body">'+
30533          '<div class="sec"><p class="sh">Summary Metrics</p>'+
30534          '<div class="msec">'+
30535          '<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>'+
30536          '<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>'+
30537          '<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>'+
30538          '<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>'+
30539          '<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>'+
30540          '<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>'+
30541          extraCards+'</div></div>'+
30542          '<div class="sec"><p class="sh">File Changes</p>'+
30543          '<div class="fcsec">'+
30544          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30545          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30546          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30547          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30548          '<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>'+
30549          '</div></div>'+
30550          (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>':'')+
30551          '<div class="sec">'+
30552          '<table><thead>'+
30553          '<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>'+
30554          '<tr><th>File</th><th>Language</th><th>Status</th>'+
30555          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30556          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30557          '</div>'+
30558          '<div class="rfoot">'+
30559          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30560          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30561          '</div>'+
30562          '</body></html>';
30563      }
30564      function doDeltaPdf(btn) {
30565        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30566      }
30567      var pdfBtn = document.getElementById('delta-pdf-btn');
30568      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30569      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30570      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30571      if (location.protocol === 'file:') {
30572        [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'; } });
30573        [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'; } });
30574      }
30575      var ppSel = document.getElementById('per-page-sel');
30576      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30577      var pathLink = document.getElementById('project-path-link');
30578      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30579    })();
30580
30581    // ── Export helpers ────────────────────────────────────────────────────────
30582    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
30583    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30584    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);}
30585    function slocMakeXlsx(fname,sd,dr){
30586      var enc=new TextEncoder();
30587      // CRC-32 table
30588      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;}
30589      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;}
30590      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30591      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30592      // Shared string table
30593      var ss=[],si={};
30594      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30595      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30596      // Worksheet builder — each WS() call gets its own row counter R
30597      function WS(){
30598        var R=0,buf=[];
30599        function cl(c){return String.fromCharCode(65+c);}
30600        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30601          '<v>'+S(v)+'</v></c>';}
30602        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30603          (st?' s="'+st+'"':'')+'>'+
30604          '<v>'+(+v)+'</v></c>';}
30605        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30606        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30607          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30608          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30609          '<sheetFormatPr defaultRowHeight="15"/>'+
30610          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30611        return{sc:sc,nc:nc,row:row,xml:xml};
30612      }
30613      // Language breakdown
30614      var lm={};
30615      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;});
30616      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30617      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30618      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30619      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30620      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30621      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30622      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):'';}
30623      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30624      // Summary sheet
30625      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30626      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30627      r1(s1(0,proj,2));
30628      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30629      r1('');
30630      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30631      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))));
30632      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))));
30633      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))));
30634      r1('');
30635      r1(s1(0,'FILE CHANGES',8));
30636      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30637      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30638      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30639      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30640      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30641      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)));
30642      if(langs.length){
30643        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30644        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30645        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)));});
30646      }
30647      r1('');r1(s1(0,'SCAN METADATA',8));
30648      r1(s1(1,_blabel)+s1(2,_clabel));
30649      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30650      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30651      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"/>');
30652      // File Delta sheet
30653      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30654      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));
30655      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)));});
30656      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30657      // Shared strings XML
30658      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30659        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30660        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30661      // XLSX file map
30662      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30663      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>',
30664        '_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>',
30665        '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>',
30666        '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>',
30667        '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>',
30668        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30669      // ZIP packer — STORED (no compression), compatible with all XLSX readers
30670      var zparts=[],zcds=[],zoff=0,znf=0;
30671      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30672       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30673      ].forEach(function(name){
30674        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30675        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]);
30676        var entry=new Uint8Array(lha.length+nb.length+sz);
30677        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30678        zparts.push(entry);
30679        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));
30680        var cde=new Uint8Array(cda.length+nb.length);
30681        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30682        zcds.push(cde);zoff+=entry.length;znf++;
30683      });
30684      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30685      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]);
30686      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30687      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30688      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30689      zout.set(new Uint8Array(ea),zpos);
30690      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30691      var xurl=URL.createObjectURL(xblob);
30692      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30693      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30694      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30695    }
30696    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;');}
30697    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30698    function getExportFilename(ext){return _exportBase+'.'+ext;}
30699
30700    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 }}'};
30701    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;}
30702    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30703    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30704    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30705    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30706    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):'';}
30707    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30708    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)]];}
30709    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30710    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)];});}
30711    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30712    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30713
30714    // ── Chart HTML report ─────────────────────────────────────────────────────
30715    function slocChartReport(fname, sd, dr) {
30716      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30717      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30718      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30719      function fmt(n){return Number(n).toLocaleString();}
30720      function px(n){return Math.round(n);}
30721      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30722      // Language map
30723      var lm={};
30724      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;});
30725      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30726
30727      // Builds onmouse* attrs for interactive tooltip on each SVG element
30728      function barTT(label,val){
30729        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30730      }
30731
30732      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30733      //    match the Language Code Delta column height) ────────────
30734      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'}];
30735      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30736      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30737      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30738      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30739      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"/>';}
30740      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30741      c1mets.forEach(function(m,i){
30742        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30743        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30744        var gMax=Math.max(m.b,m.c)*1.15||1;
30745        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30746        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>';
30747        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))+'/>';
30748        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>';
30749        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))+'/>';
30750        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>';
30751        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>';
30752        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>';
30753      });
30754      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>';
30755      c1+='</svg>';
30756
30757      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30758      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'}];
30759      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30760      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30761      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30762      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30763      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30764      mets.forEach(function(m,i){
30765        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30766        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30767        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30768        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>';
30769        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30770        if(bw>=52){
30771          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>';
30772        }else{
30773          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30774          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>';
30775        }
30776      });
30777      c2+='</svg>';
30778
30779      // ── Chart 3: Language Code Delta ─────────────────────────────────────
30780      var c3='';
30781      if(langs.length){
30782        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30783        var C3W=550,c3LW=124,c3FW=52;
30784        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30785        var L3rH=30,C3H=langs.length*L3rH+20;
30786        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30787        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30788        langs.forEach(function(l,i){
30789          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30790          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30791          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30792          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>';
30793          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':''))+'/>';
30794          if(bw>=48){
30795            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>';
30796          }else{
30797            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30798            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>';
30799          }
30800          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>';
30801        });
30802        c3+='</svg>';
30803      }
30804
30805      // ── Chart 4: File Change Donut — centered pie with legend below
30806      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;});
30807      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30808      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30809      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">';
30810      var ang=-Math.PI/2;
30811      segs.forEach(function(s){
30812        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30813        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30814        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30815        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30816        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30817        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)+'%')+'/>';
30818        ang+=sw;
30819      });
30820      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>';
30821      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>';
30822      segs.forEach(function(s,i){
30823        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30824        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30825        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>';
30826      });
30827      c4+='</svg>';
30828
30829      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30830      var ttJs='var tt=document.getElementById("ox-tt");'+
30831        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30832        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30833        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30834        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30835        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30836        'function oxHT(){tt.style.display="none";}';
30837
30838      // body max-width keeps charts from inflating beyond design dimensions on
30839      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30840      // each chart's height blows up proportionally, breaking the one-page layout.
30841      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;}'+
30842        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30843        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30844        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30845        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30846        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30847        'svg{display:block;}'+
30848        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30849        '#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;}'+
30850        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30851      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30852        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30853        '<div id="ox-tt"><\/div>'+
30854        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
30855        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
30856        '<div class="two-col">'+
30857        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
30858        '<div class="leg">'+
30859        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30860        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30861        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30862        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
30863        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30864        '<\/div>'+
30865        '<div class="two-col">'+
30866        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30867        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30868        '<\/div>'+
30869        '<script>'+ttJs+'<\/script>'+
30870        '<\/body><\/html>';
30871      slocDownload(html, fname, 'text/html;charset=utf-8;');
30872    }
30873    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30874    window.buildDeltaChartsHtml = function() {
30875      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30876      var sd=_sd;
30877      var projEl=document.querySelector('[data-folder]');
30878      var proj=projEl?projEl.getAttribute('data-folder'):'';
30879      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30880      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30881      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30882      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30883      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";}';
30884      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);}';
30885      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30886        '<div id="ox-tt"><\/div>'+
30887        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30888        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30889        '<div class="two-col">'+
30890        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30891        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30892        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30893        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30894        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30895        '<\/div>'+
30896        '<div class="two-col">'+
30897        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30898        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30899        '<\/div>'+
30900        '<script>'+ttJs+'<\/script>'+
30901        '<\/body><\/html>';
30902    };
30903    // ── Inline delta charts ────────────────────────────────────────────────────
30904    var _icTT=document.getElementById('ic-tt');
30905    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30906    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';};
30907    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30908    window.addEventListener('blur',function(){window.icHT();});
30909    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30910    (function(){
30911      // Theme-aware palette — matches the canonical scheme used by /test-metrics
30912      // charts so every page renders bars/text/grid with the same colours and
30913      // adapts to dark mode (see Design section in CLAUDE.md).
30914      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30915      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30916      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30917      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30918      // washed) so the chart reads with the same weight as /test-metrics.
30919      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30920      var FADE=dark?'#524238':'#e6d0bf';
30921      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30922      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30923      function fmt(n){return Number(n).toLocaleString();}
30924      function px(n){return Math.round(n);}
30925      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30926      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30927      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);});}
30928      var dr=getDeltaExportRows(),sd=_sd,lm={};
30929      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;});
30930      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30931      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30932      // the bars are as tall as the (usually taller) Language Code Delta sibling that
30933      // shares the same grid row, instead of sitting short at the top.
30934      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}];
30935      function drawC1(){
30936        var C1W=600,C1H=188;
30937        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30938        if(host&&card&&host.clientWidth>0){
30939          var avW=host.clientWidth;
30940          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30941          var wantH=availPx*C1W/avW;
30942          if(wantH>C1H)C1H=wantH;
30943        }
30944        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30945        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30946        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"/>';}
30947        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30948        c1mets.forEach(function(m,i){
30949          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30950          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30951          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30952          var gMax=Math.max(m.b,m.c)*1.15||1;
30953          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30954          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>';
30955          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"/>';
30956          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>';
30957          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"/>';
30958          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>';
30959          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>';
30960          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>';
30961        });
30962        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>';
30963        c1+='</svg>';
30964        return c1;
30965      }
30966      var c1=drawC1();
30967      // Chart 2: Delta by Metric
30968      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}];
30969      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30970      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;
30971      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30972      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30973      mets.forEach(function(m,i){
30974        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);
30975        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>';
30976        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30977        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>';}
30978        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>';}
30979      });
30980      c2+='</svg>';
30981      // Chart 3: Language Code Delta
30982      var c3='';
30983      if(langs.length){
30984        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30985        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;
30986        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30987        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30988        langs.forEach(function(l,i){
30989          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);
30990          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>';
30991          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"/>';
30992          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>';}
30993          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>';}
30994          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>';
30995        });
30996        c3+='</svg>';
30997      }
30998      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30999      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
31000      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;});
31001      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
31002      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
31003      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);
31004      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;
31005      if(segs.length===1){
31006        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
31007        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+'"/>';
31008      } else {
31009        // Give every visible slice a small minimum sweep, taken from the largest
31010        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
31011        // arc whose start and end points coincide, so SVG renders nothing (blank).
31012        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
31013        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
31014        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
31015        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
31016        segs.forEach(function(s,si){
31017          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
31018          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);
31019          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);
31020          var pct=Math.round(s.v/tot*100);
31021          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"/>';
31022          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>';}
31023          ang+=sw;
31024        });
31025      }
31026      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
31027      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
31028      segs.forEach(function(s,i){
31029        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
31030        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
31031        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
31032        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
31033        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
31034        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>';
31035        c4+='</g>';
31036      });
31037      c4+='</svg>';
31038      // Inject the fixed-height siblings first so the grid row settles to the (taller)
31039      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
31040      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
31041      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);}
31042      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
31043      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
31044      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
31045
31046      // Compare Timeline chart (Baseline vs Current, 2 points)
31047      (function() {
31048        var activeCmpMetric='code';
31049        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
31050        function renderCmpTL(metric, targetSvg, targetH) {
31051          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
31052          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
31053          svg.setAttribute('height',H);
31054          var pad={l:62,r:20,t:32,b:72};
31055          var dark=document.body.classList.contains('dark-theme');
31056          var cmpPts=[
31057            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
31058            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
31059          ];
31060          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
31061          var valid=pts.filter(function(v){return v!=null;});
31062          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;}
31063          var minV=0,maxV=Math.max.apply(null,valid);
31064          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
31065          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
31066          var cx0=pad.l,cx1=pad.l+plotW;
31067          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
31068          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
31069          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
31070          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
31071          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
31072          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();}
31073          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
31074          var parts=[];
31075          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
31076          for(var gi=0;gi<5;gi++){
31077            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
31078            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
31079            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
31080          }
31081          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+'"/>');
31082          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"/>');
31083          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
31084                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
31085          dotPts.forEach(function(pt){
31086            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>');
31087            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"/>');
31088            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>');
31089            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>');
31090          });
31091          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
31092          svg.setAttribute('viewBox','0 0 '+W+' '+H);
31093          svg.innerHTML=parts.join('');
31094          // Hover: crosshair + tooltip (matches multi-scan timeline)
31095          var cmpTT=document.getElementById('ic-tt');
31096          svg.onmousemove=function(e){
31097            var rect=svg.getBoundingClientRect();
31098            var scaleX=W/rect.width;
31099            var mouseX=(e.clientX-rect.left)*scaleX;
31100            var nearest=-1,minDist=Infinity;
31101            var cxArr=[cx0,cx1];
31102            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;}}
31103            if(nearest<0)return;
31104            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
31105            var xhair=svg.querySelector('.cmp-xhair');
31106            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
31107            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"/>';
31108            if(!cmpTT)return;
31109            var clbl=cmpPts[nearest].label;
31110            var scanLbl=nearest===0?'Baseline':'Current';
31111            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>';
31112            var bx=rect.left+(nc/W*rect.width)+18;
31113            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
31114            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
31115          };
31116          svg.onmouseleave=function(){
31117            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
31118            if(cmpTT)cmpTT.style.display='none';
31119          };
31120        }
31121        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
31122          btn.addEventListener('click',function(){
31123            activeCmpMetric=this.dataset.cmpMetric;
31124            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
31125            this.classList.add('active');
31126            renderCmpTL(activeCmpMetric);
31127          });
31128        });
31129        var ttgl=document.getElementById('theme-toggle');
31130        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
31131        if(typeof ResizeObserver!=='undefined'){
31132          var cmpSvg=document.getElementById('cmp-tl-svg');
31133          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
31134        }
31135        // Expose the timeline renderer + current metric so the Full View modal can
31136        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
31137        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
31138        window.__sdGetMetric=function(){return activeCmpMetric;};
31139        renderCmpTL(activeCmpMetric);
31140      })();
31141
31142      // HTML legend hover -> highlight matching SVG bars within the SAME card only
31143      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
31144        var metric=leg.getAttribute('data-highlight');
31145        var parentCard=leg.closest('.ic-card');
31146        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
31147        if(!chartEl)return;
31148        leg.addEventListener('mouseenter',function(){
31149          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
31150            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';}
31151            else{x.style.opacity='0.28';}
31152          });
31153        });
31154        leg.addEventListener('mouseleave',function(){
31155          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
31156        });
31157      });
31158
31159      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
31160      (function(){
31161        var ov=document.getElementById('ic-svg-modal-ov');
31162        var body=document.getElementById('ic-svg-modal-body');
31163        var ttl=document.getElementById('ic-svg-modal-title');
31164        var closeBtn=document.getElementById('ic-svg-modal-close');
31165        if(!ov||!body)return;
31166        function close(){
31167          ov.classList.remove('open');body.innerHTML='';
31168          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
31169          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
31170        }
31171        function open(srcId,title){
31172          var src=document.getElementById(srcId);if(!src)return;
31173          if(ttl)ttl.textContent=title||'';
31174          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
31175          // snapshot stretches and loses interactivity. Re-render it live into the modal at
31176          // full size instead — keeps proportions, animation, crosshair, tooltip and the
31177          // metric tabs working exactly like the inline chart.
31178          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
31179            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
31180            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
31181            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('');
31182            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>';
31183            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
31184            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
31185            ov.classList.add('open');
31186            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
31187            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;}
31188            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
31189              b.addEventListener('click',function(){
31190                if(!window.__sdFvTL)return;
31191                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
31192                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
31193                this.classList.add('active');
31194                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
31195              });
31196            });
31197            return;
31198          }
31199          var card=src.closest('.ic-card');
31200          var legHtml='';
31201          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
31202          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
31203          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;}
31204          body.innerHTML=legHtml+inner;
31205          var svg=body.querySelector('svg');
31206          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
31207          addTT(body);
31208          ov.classList.add('open');
31209        }
31210        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
31211          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
31212        });
31213        if(closeBtn)closeBtn.addEventListener('click',close);
31214        ov.addEventListener('click',function(e){if(e.target===ov)close();});
31215        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
31216      })();
31217
31218      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
31219    })();
31220  </script>
31221  {{ toast_assets|safe }}
31222  <script nonce="{{ csp_nonce }}">
31223  (function(){
31224    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'}];
31225    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);});}
31226    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31227    function init(){
31228      var btn=document.getElementById('settings-btn');if(!btn)return;
31229      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31230      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>';
31231      document.body.appendChild(m);
31232      var g=document.getElementById('scheme-grid');
31233      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);});
31234      var cl=document.getElementById('settings-close');
31235      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);});})();
31236      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');});
31237      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31238      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31239    }
31240    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
31241  }());
31242  </script>
31243  <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]';
31244  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;}
31245  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>
31246</body>
31247</html>
31248"##,
31249    ext = "html"
31250)]
31251// Template structs need many bool fields to pass Askama rendering flags.
31252#[allow(clippy::struct_excessive_bools)]
31253struct CompareTemplate {
31254    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
31255    loading_overlay: String,
31256    version: &'static str,
31257    project_label: String,
31258    baseline_git_commit: String,
31259    current_git_commit: String,
31260    baseline_run_id: String,
31261    current_run_id: String,
31262    baseline_run_id_short: String,
31263    current_run_id_short: String,
31264    baseline_timestamp: String,
31265    baseline_timestamp_utc_ms: i64,
31266    current_timestamp: String,
31267    current_timestamp_utc_ms: i64,
31268    project_path: String,
31269    baseline_code: u64,
31270    current_code: u64,
31271    code_lines_delta_str: String,
31272    code_lines_delta_class: String,
31273    baseline_files: u64,
31274    current_files: u64,
31275    files_analyzed_delta_str: String,
31276    files_analyzed_delta_class: String,
31277    baseline_comments: u64,
31278    current_comments: u64,
31279    comment_lines_delta_str: String,
31280    comment_lines_delta_class: String,
31281    baseline_code_fmt: String,
31282    current_code_fmt: String,
31283    baseline_files_fmt: String,
31284    current_files_fmt: String,
31285    baseline_comments_fmt: String,
31286    current_comments_fmt: String,
31287    code_lines_pct_str: String,
31288    files_analyzed_pct_str: String,
31289    comment_lines_pct_str: String,
31290    code_lines_added: i64,
31291    code_lines_removed: i64,
31292    /// Code lines residing in files modified between the two scans (current-scan counts).
31293    code_lines_modified: i64,
31294    /// Code lines residing in files identical between the two scans.
31295    code_lines_unmodified: i64,
31296    /// Sum of added + removed + modified + unmodified code-line metrics.
31297    code_lines_total: i64,
31298    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
31299    new_scope: bool,
31300    churn_rate_str: String,
31301    churn_rate_class: String,
31302    scope_flag: bool,
31303    files_added: usize,
31304    files_removed: usize,
31305    files_modified: usize,
31306    files_unchanged: usize,
31307    files_total: usize,
31308    file_rows: Vec<CompareFileDeltaRow>,
31309    baseline_git_author: Option<String>,
31310    current_git_author: Option<String>,
31311    baseline_git_branch: String,
31312    current_git_branch: String,
31313    baseline_git_tags: Option<String>,
31314    current_git_tags: Option<String>,
31315    baseline_git_commit_date: Option<String>,
31316    current_git_commit_date: Option<String>,
31317    project_name: String,
31318    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
31319    submodule_options: Vec<String>,
31320    /// True when either run has submodule data — controls whether the scope bar is shown.
31321    has_any_submodule_data: bool,
31322    /// The submodule currently being compared, if the `sub` query param was provided.
31323    active_submodule: Option<String>,
31324    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
31325    super_scope_active: bool,
31326    csp_nonce: String,
31327    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
31328    toast_assets: String,
31329    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
31330    coverage_delta_card: String,
31331    baseline_test_count: u64,
31332    current_test_count: u64,
31333    baseline_coverage_pct: Option<f64>,
31334    current_coverage_pct: Option<f64>,
31335}
31336
31337// ── LoginTemplate ──────────────────────────────────────────────────────────────
31338
31339#[derive(Template)]
31340#[template(
31341    source = r##"
31342<!doctype html>
31343<html lang="en">
31344<head>
31345  <meta charset="utf-8">
31346  <meta name="viewport" content="width=device-width, initial-scale=1">
31347  <title>OxideSLOC | Sign In</title>
31348  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31349  <style nonce="{{ csp_nonce }}">
31350    :root {
31351      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31352      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31353      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31354      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31355    }
31356    *{box-sizing:border-box;}
31357    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);}
31358    .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);}
31359    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31360    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31361    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31362    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31363    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31364    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31365    .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;}
31366    @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));}}
31367    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31368    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31369    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31370    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31371    .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;}
31372    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31373    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;}
31374    input[type=password]:focus{border-color:var(--oxide);}
31375    .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;}
31376    .btn:hover{opacity:.88;}
31377    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31378    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31379  </style>
31380</head>
31381<body>
31382  <div class="background-watermarks" aria-hidden="true">
31383    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31384    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31385    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31386    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31387    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31388    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31389    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31390  </div>
31391  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31392<nav class="top-nav">
31393  <a class="brand" href="/">
31394    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31395    <span class="brand-title">OxideSLOC</span>
31396  </a>
31397</nav>
31398<main class="page">
31399  <div class="card">
31400    <h1>Sign In</h1>
31401    <p class="subtitle">Enter the API key printed when the server started.</p>
31402    {% if has_error %}
31403    <div class="error">Incorrect API key — please try again.</div>
31404    {% endif %}
31405    <form method="POST" action="/auth/login">
31406      <input type="hidden" name="next" value="{{ next_url|e }}">
31407      <label for="key">API Key</label>
31408      <input id="key" type="password" name="key" autocomplete="current-password"
31409             placeholder="Paste your API key here" autofocus>
31410      <button type="submit" class="btn">Sign In</button>
31411    </form>
31412    <p class="hint">
31413      The API key was printed in the terminal when the server started.<br>
31414      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31415      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31416    </p>
31417  </div>
31418</main>
31419<script nonce="{{ csp_nonce }}">
31420(function() {
31421  (function randomizeWatermarks() {
31422    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31423    if (!wms.length) return;
31424    var placed = [];
31425    function tooClose(top, left) {
31426      for (var i = 0; i < placed.length; i++) {
31427        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31428        if (dt < 16 && dl < 12) return true;
31429      }
31430      return false;
31431    }
31432    function pick(leftBand) {
31433      for (var attempt = 0; attempt < 50; attempt++) {
31434        var top = Math.random() * 88 + 2;
31435        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31436        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31437      }
31438      var top = Math.random() * 88 + 2;
31439      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31440      placed.push([top, left]); return [top, left];
31441    }
31442    var half = Math.floor(wms.length / 2);
31443    wms.forEach(function (img, i) {
31444      var pos = pick(i < half);
31445      var size = Math.floor(Math.random() * 100 + 120);
31446      var rot = (Math.random() * 360).toFixed(1);
31447      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31448      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;
31449    });
31450  })();
31451  (function spawnCodeParticles() {
31452    var container = document.getElementById('code-particles');
31453    if (!container) return;
31454    var snippets = [
31455      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31456      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31457      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31458      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31459      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31460    ];
31461    var count = 38;
31462    for (var i = 0; i < count; i++) {
31463      (function(idx) {
31464        var el = document.createElement('span');
31465        el.className = 'code-particle';
31466        el.textContent = snippets[idx % snippets.length];
31467        var left = Math.random() * 94 + 2;
31468        var top = Math.random() * 88 + 6;
31469        var dur = (Math.random() * 10 + 9).toFixed(1);
31470        var delay = (Math.random() * 18).toFixed(1);
31471        var rot = (Math.random() * 26 - 13).toFixed(1);
31472        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31473        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31474        container.appendChild(el);
31475      })(i);
31476    }
31477  })();
31478})();
31479</script>
31480</body>
31481</html>
31482"##,
31483    ext = "html"
31484)]
31485pub(crate) struct LoginTemplate {
31486    pub(crate) csp_nonce: String,
31487    pub(crate) has_error: bool,
31488    pub(crate) next_url: String,
31489    pub(crate) lockout_threshold: u32,
31490}
31491
31492// ── REST API reference page ────────────────────────────────────────────────────
31493
31494#[derive(Template)]
31495#[template(
31496    source = r##"
31497<!doctype html>
31498<html lang="en">
31499<head>
31500  <meta charset="utf-8">
31501  <meta name="viewport" content="width=device-width, initial-scale=1">
31502  <title>OxideSLOC — REST API Reference</title>
31503  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31504  <style nonce="{{ csp_nonce }}">
31505    :root {
31506      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31507      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31508      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31509      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31510      --success:#16a34a;
31511    }
31512    body.dark-theme {
31513      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31514      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31515    }
31516    *{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;}
31517    .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);}
31518    .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;}
31519    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31520    .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));}
31521    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31522    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31523    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31524    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31525    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31526    @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; } }
31527    .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;}
31528    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31529    .nav-pill.active{background:rgba(255,255,255,0.22);}
31530    .nav-dropdown{position:relative;display:inline-flex;}
31531    .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;}
31532    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31533    .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;}
31534    .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;}
31535    .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);}
31536    .nav-dropdown-menu a:last-child{border-bottom:none;}
31537    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31538    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31539    .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;}
31540    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31541    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31542    .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;}
31543    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31544    .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);}
31545    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31546    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31547    .settings-modal-body{padding:14px 16px 16px;}
31548    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31549    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31550    .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;}
31551    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31552    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31553    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31554    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31555    .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;}
31556    .tz-select:focus{border-color:var(--oxide);}
31557    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31558    .page-header{margin-bottom:28px;}
31559    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31560    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31561    .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;}
31562    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31563    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31564    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31565    .callout strong{font-weight:800;}
31566    .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;}
31567    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31568    .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;}
31569    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31570    .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;}
31571    body.dark-theme .base-url-value{color:var(--accent);}
31572    .section{margin-bottom:36px;}
31573    .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);}
31574    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31575    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31576    .ep-header:hover{background:var(--surface-2);}
31577    .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;}
31578    .method.get{background:#dcfce7;color:#166534;}
31579    .method.post{background:#dbeafe;color:#1e40af;}
31580    .method.delete{background:#fee2e2;color:#991b1b;}
31581    body.dark-theme .method.get{background:#14532d;color:#86efac;}
31582    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31583    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31584    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31585    .ep-path .param{color:var(--oxide-2);}
31586    body.dark-theme .ep-path .param{color:var(--oxide);}
31587    .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;}
31588    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31589    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31590    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31591    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31592    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31593    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31594    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31595    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31596    .ep-card.open .chevron{transform:rotate(180deg);}
31597    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31598    .ep-card.open .ep-body{display:block;}
31599    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31600    .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;}
31601    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31602    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31603    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31604    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31605    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);}
31606    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31607    table.params tr:last-child td{border-bottom:none;}
31608    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31609    .pt-type{color:var(--muted-2);font-size:12px;}
31610    .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;}
31611    .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;}
31612    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31613    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31614    details.schema{margin-bottom:14px;}
31615    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;}
31616    details.schema summary:hover{color:var(--text);}
31617    .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;}
31618    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31619    .curl-wrap{position:relative;}
31620    .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;}
31621    .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;}
31622    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31623    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31624    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31625    .webhook-note a{color:var(--accent-2);text-decoration:none;}
31626    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31627    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31628    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31629    .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;}
31630    @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));}}
31631    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31632    .site-footer a{color:var(--muted);}
31633  </style>
31634</head>
31635<body>
31636  <div class="background-watermarks" aria-hidden="true">
31637    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31638    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31639    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31640    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31641    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31642    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31643    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31644  </div>
31645  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31646  <div class="top-nav">
31647    <div class="top-nav-inner">
31648      <a class="brand" href="/">
31649        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31650        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31651      </a>
31652      <div class="nav-right">
31653        <a class="nav-pill" href="/">Home</a>
31654        <div class="nav-dropdown">
31655          <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>
31656          <div class="nav-dropdown-menu">
31657            <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>
31658          </div>
31659        </div>
31660        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31661        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31662        <div class="nav-dropdown">
31663          <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>
31664          <div class="nav-dropdown-menu">
31665            <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>
31666          </div>
31667        </div>
31668        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31669          <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>
31670        </button>
31671        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31672          <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>
31673          <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>
31674        </button>
31675      </div>
31676    </div>
31677  </div>
31678
31679  <div class="page">
31680    <div class="page-header">
31681      <h1 class="page-title">REST API Reference</h1>
31682      <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>
31683    </div>
31684
31685    {% if has_api_key %}
31686    <div class="callout key-set">
31687      <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>
31688      <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>
31689    </div>
31690    {% else %}
31691    <div class="callout no-key">
31692      <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>
31693      <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>
31694    </div>
31695    {% endif %}
31696
31697    <div class="base-url-bar">
31698      <span class="base-url-label">Base URL</span>
31699      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31700    </div>
31701
31702    <!-- Health -->
31703    <div class="section">
31704      <h2 class="section-title">Health &amp; Status</h2>
31705      <div class="ep-card">
31706        <div class="ep-header">
31707          <span class="method get">GET</span>
31708          <span class="ep-path">/healthz</span>
31709          <span class="auth-badge public">Public</span>
31710          <span class="ep-desc">Server liveness check</span>
31711          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31712        </div>
31713        <div class="ep-body">
31714          <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>
31715          <p class="params-heading">Response</p>
31716          <div class="schema-block">200 OK
31717Content-Type: text/plain
31718
31719ok</div>
31720          <p class="curl-heading">Example</p>
31721          <div class="curl-wrap">
31722            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31723            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31724          </div>
31725        </div>
31726      </div>
31727    </div>
31728
31729    <!-- Badges -->
31730    <div class="section">
31731      <h2 class="section-title">Badges</h2>
31732      <div class="ep-card">
31733        <div class="ep-header">
31734          <span class="method get">GET</span>
31735          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31736          <span class="auth-badge public">Public</span>
31737          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31738          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31739        </div>
31740        <div class="ep-body">
31741          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31742          <p class="params-heading">Path Parameters</p>
31743          <table class="params">
31744            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31745            <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>
31746          </table>
31747          <p class="curl-heading">Example</p>
31748          <div class="curl-wrap">
31749            <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>
31750            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31751          </div>
31752        </div>
31753      </div>
31754    </div>
31755
31756    <!-- Metrics -->
31757    <div class="section">
31758      <h2 class="section-title">Metrics</h2>
31759
31760      <div class="ep-card">
31761        <div class="ep-header">
31762          <span class="method get">GET</span>
31763          <span class="ep-path">/api/metrics/latest</span>
31764          <span class="auth-badge protected">Protected</span>
31765          <span class="ep-desc">Latest scan metrics (JSON)</span>
31766          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31767        </div>
31768        <div class="ep-body">
31769          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31770          <details class="schema"><summary>Response schema</summary>
31771<div class="schema-block">{
31772  "run_id":    string,        // UUID
31773  "timestamp": string,        // ISO-8601 UTC
31774  "project":   string,        // scanned root path
31775  "summary": {
31776    "files_analyzed":       number,
31777    "files_skipped":        number,
31778    "code_lines":           number,
31779    "comment_lines":        number,
31780    "blank_lines":          number,
31781    "total_physical_lines": number,
31782    "functions":            number,
31783    "classes":              number,
31784    "variables":            number,
31785    "imports":              number
31786  },
31787  "languages": [
31788    { "name": string, "files": number, "code_lines": number,
31789      "comment_lines": number, "blank_lines": number,
31790      "functions": number, "classes": number,
31791      "variables": number, "imports": number }
31792  ]
31793}</div></details>
31794          <p class="curl-heading">Example</p>
31795          <div class="curl-wrap">
31796            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31797  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31798            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31799          </div>
31800        </div>
31801      </div>
31802
31803      <div class="ep-card">
31804        <div class="ep-header">
31805          <span class="method get">GET</span>
31806          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31807          <span class="auth-badge protected">Protected</span>
31808          <span class="ep-desc">Metrics for a specific run</span>
31809          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31810        </div>
31811        <div class="ep-body">
31812          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31813          <p class="params-heading">Path Parameters</p>
31814          <table class="params">
31815            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31816            <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>
31817          </table>
31818          <p class="curl-heading">Example</p>
31819          <div class="curl-wrap">
31820            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31821  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
31822            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31823          </div>
31824        </div>
31825      </div>
31826
31827      <div class="ep-card">
31828        <div class="ep-header">
31829          <span class="method get">GET</span>
31830          <span class="ep-path">/api/metrics/history</span>
31831          <span class="auth-badge protected">Protected</span>
31832          <span class="ep-desc">Paginated scan history</span>
31833          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31834        </div>
31835        <div class="ep-body">
31836          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31837          <p class="params-heading">Query Parameters</p>
31838          <table class="params">
31839            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31840            <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>
31841            <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>
31842          </table>
31843          <details class="schema"><summary>Response schema</summary>
31844<div class="schema-block">[{
31845  "run_id":         string,
31846  "timestamp":      string,   // ISO-8601 UTC
31847  "commit":         string | null,
31848  "branch":         string | null,
31849  "tags":           string[],
31850  "code_lines":     number,
31851  "comment_lines":  number,
31852  "blank_lines":    number,
31853  "physical_lines": number,
31854  "files_analyzed": number,
31855  "project_label":  string,
31856  "html_url":       string | null
31857}]</div></details>
31858          <p class="curl-heading">Example</p>
31859          <div class="curl-wrap">
31860            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31861  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31862            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31863          </div>
31864        </div>
31865      </div>
31866
31867      <div class="ep-card">
31868        <div class="ep-header">
31869          <span class="method get">GET</span>
31870          <span class="ep-path">/api/project-history</span>
31871          <span class="auth-badge protected">Protected</span>
31872          <span class="ep-desc">Project-level scan summary</span>
31873          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31874        </div>
31875        <div class="ep-body">
31876          <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>
31877          <p class="params-heading">Query Parameters</p>
31878          <table class="params">
31879            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31880            <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>
31881          </table>
31882          <details class="schema"><summary>Response schema</summary>
31883<div class="schema-block">{
31884  "scan_count":           number,
31885  "last_scan_id":         string | null,
31886  "last_scan_timestamp":  string | null,  // ISO-8601
31887  "last_scan_code_lines": number | null,
31888  "last_git_branch":      string | null,
31889  "last_git_commit":      string | null
31890}</div></details>
31891          <p class="curl-heading">Example</p>
31892          <div class="curl-wrap">
31893            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31894  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31895            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31896          </div>
31897        </div>
31898      </div>
31899
31900      <div class="ep-card">
31901        <div class="ep-header">
31902          <span class="method get">GET</span>
31903          <span class="ep-path">/api/metrics/submodules</span>
31904          <span class="auth-badge protected">Protected</span>
31905          <span class="ep-desc">List known git submodules across scans</span>
31906          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31907        </div>
31908        <div class="ep-body">
31909          <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>
31910          <p class="params-heading">Query Parameters</p>
31911          <table class="params">
31912            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31913            <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>
31914          </table>
31915          <details class="schema"><summary>Response schema</summary>
31916<div class="schema-block">[{
31917  "name":          string,  // submodule name
31918  "relative_path": string   // path relative to the project root
31919}]</div></details>
31920          <p class="curl-heading">Example</p>
31921          <div class="curl-wrap">
31922            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31923  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31924            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31925          </div>
31926        </div>
31927      </div>
31928    </div>
31929
31930    <!-- Async Run Status -->
31931    <div class="section">
31932      <h2 class="section-title">Async Run Status</h2>
31933
31934      <div class="ep-card">
31935        <div class="ep-header">
31936          <span class="method get">GET</span>
31937          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31938          <span class="auth-badge protected">Protected</span>
31939          <span class="ep-desc">Poll scan completion</span>
31940          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31941        </div>
31942        <div class="ep-body">
31943          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31944          <details class="schema"><summary>Response schema</summary>
31945<div class="schema-block">// Running
31946{ "state": "running",  "elapsed_secs": number }
31947
31948// Complete
31949{ "state": "complete", "run_id": string }
31950
31951// Failed
31952{ "state": "failed",   "message": string }</div></details>
31953          <p class="curl-heading">Example</p>
31954          <div class="curl-wrap">
31955            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31956  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
31957            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31958          </div>
31959        </div>
31960      </div>
31961
31962      <div class="ep-card">
31963        <div class="ep-header">
31964          <span class="method get">GET</span>
31965          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31966          <span class="auth-badge protected">Protected</span>
31967          <span class="ep-desc">Poll PDF generation readiness</span>
31968          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31969        </div>
31970        <div class="ep-body">
31971          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31972          <details class="schema"><summary>Response schema</summary>
31973<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31974          <p class="curl-heading">Example</p>
31975          <div class="curl-wrap">
31976            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31977  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
31978            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31979          </div>
31980        </div>
31981      </div>
31982
31983      <div class="ep-card">
31984        <div class="ep-header">
31985          <span class="method post">POST</span>
31986          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31987          <span class="auth-badge protected">Protected</span>
31988          <span class="ep-desc">Cancel a running scan</span>
31989          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31990        </div>
31991        <div class="ep-body">
31992          <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>
31993          <p class="curl-heading">Example</p>
31994          <div class="curl-wrap">
31995            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31996  -H "Authorization: Bearer $SLOC_API_KEY" \
31997  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
31998            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31999          </div>
32000        </div>
32001      </div>
32002    </div>
32003
32004    <!-- Run Management -->
32005    <div class="section">
32006      <h2 class="section-title">Run Management</h2>
32007
32008      <div class="ep-card">
32009        <div class="ep-header">
32010          <span class="method get">GET</span>
32011          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
32012          <span class="auth-badge protected">Protected</span>
32013          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
32014          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32015        </div>
32016        <div class="ep-body">
32017          <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>
32018          <p class="params-heading">Path Parameters</p>
32019          <table class="params">
32020            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32021            <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>
32022          </table>
32023          <details class="schema"><summary>Response</summary>
32024<div class="schema-block">200 OK — Content-Type: application/zip
32025Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
32026
32027404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
32028          <p class="curl-heading">Example</p>
32029          <div class="curl-wrap">
32030            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32031  -o run.zip \
32032  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
32033            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
32034          </div>
32035        </div>
32036      </div>
32037
32038      <div class="ep-card">
32039        <div class="ep-header">
32040          <span class="method delete">DELETE</span>
32041          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
32042          <span class="auth-badge protected">Protected</span>
32043          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
32044          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32045        </div>
32046        <div class="ep-body">
32047          <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>
32048          <p class="params-heading">Path Parameters</p>
32049          <table class="params">
32050            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32051            <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>
32052          </table>
32053          <details class="schema"><summary>Response</summary>
32054<div class="schema-block">204 No Content — run successfully deleted
32055
32056500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
32057          <p class="curl-heading">Example</p>
32058          <div class="curl-wrap">
32059            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
32060  -H "Authorization: Bearer $SLOC_API_KEY" \
32061  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
32062            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
32063          </div>
32064        </div>
32065      </div>
32066
32067      <div class="ep-card">
32068        <div class="ep-header">
32069          <span class="method post">POST</span>
32070          <span class="ep-path">/api/runs/cleanup</span>
32071          <span class="auth-badge protected">Protected</span>
32072          <span class="ep-desc">Bulk delete runs older than N days</span>
32073          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32074        </div>
32075        <div class="ep-body">
32076          <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>
32077          <p class="params-heading">Request Body (application/json)</p>
32078          <table class="params">
32079            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32080            <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>
32081          </table>
32082          <details class="schema"><summary>Response schema</summary>
32083<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
32084          <p class="curl-heading">Example — delete runs older than 60 days</p>
32085          <div class="curl-wrap">
32086            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
32087  -H "Authorization: Bearer $SLOC_API_KEY" \
32088  -H "Content-Type: application/json" \
32089  -d '{"older_than_days":60}' \
32090  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
32091            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
32092          </div>
32093        </div>
32094      </div>
32095    </div>
32096
32097    <!-- Retention Policy -->
32098    <div class="section">
32099      <h2 class="section-title">Retention Policy</h2>
32100
32101      <div class="ep-card">
32102        <div class="ep-header">
32103          <span class="method get">GET</span>
32104          <span class="ep-path">/api/cleanup-policy</span>
32105          <span class="auth-badge protected">Protected</span>
32106          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
32107          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32108        </div>
32109        <div class="ep-body">
32110          <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>
32111          <details class="schema"><summary>Response schema</summary>
32112<div class="schema-block">{
32113  "policy": {
32114    "enabled":       boolean,
32115    "max_age_days":  number | null,   // delete runs older than N days
32116    "max_run_count": number | null,   // keep only the N most recent runs
32117    "interval_hours": number          // hours between background passes
32118  } | null,
32119  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
32120  "last_run_deleted": number | null   // runs deleted in last pass
32121}</div></details>
32122          <p class="curl-heading">Example</p>
32123          <div class="curl-wrap">
32124            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32125  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32126            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
32127          </div>
32128        </div>
32129      </div>
32130
32131      <div class="ep-card">
32132        <div class="ep-header">
32133          <span class="method post">POST</span>
32134          <span class="ep-path">/api/cleanup-policy</span>
32135          <span class="auth-badge protected">Protected</span>
32136          <span class="ep-desc">Save or update the retention policy</span>
32137          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32138        </div>
32139        <div class="ep-body">
32140          <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>
32141          <p class="params-heading">Request Body (application/json)</p>
32142          <table class="params">
32143            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32144            <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>
32145            <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>
32146            <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>
32147            <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>
32148          </table>
32149          <details class="schema"><summary>Response</summary>
32150<div class="schema-block">204 No Content — policy saved and task (re)started
32151
32152500 Internal Server Error — { "error": string }</div></details>
32153          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
32154          <div class="curl-wrap">
32155            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
32156  -H "Authorization: Bearer $SLOC_API_KEY" \
32157  -H "Content-Type: application/json" \
32158  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
32159  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32160            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
32161          </div>
32162        </div>
32163      </div>
32164
32165      <div class="ep-card">
32166        <div class="ep-header">
32167          <span class="method post">POST</span>
32168          <span class="ep-path">/api/cleanup-policy/run-now</span>
32169          <span class="auth-badge protected">Protected</span>
32170          <span class="ep-desc">Trigger an immediate cleanup pass</span>
32171          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32172        </div>
32173        <div class="ep-body">
32174          <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>
32175          <details class="schema"><summary>Response schema</summary>
32176<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
32177          <p class="curl-heading">Example</p>
32178          <div class="curl-wrap">
32179            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
32180  -H "Authorization: Bearer $SLOC_API_KEY" \
32181  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
32182            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
32183          </div>
32184        </div>
32185      </div>
32186
32187      <div class="ep-card">
32188        <div class="ep-header">
32189          <span class="method delete">DELETE</span>
32190          <span class="ep-path">/api/cleanup-policy</span>
32191          <span class="auth-badge protected">Protected</span>
32192          <span class="ep-desc">Remove the retention policy and stop the background task</span>
32193          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32194        </div>
32195        <div class="ep-body">
32196          <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>
32197          <details class="schema"><summary>Response</summary>
32198<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
32199          <p class="curl-heading">Example</p>
32200          <div class="curl-wrap">
32201            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
32202  -H "Authorization: Bearer $SLOC_API_KEY" \
32203  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
32204            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
32205          </div>
32206        </div>
32207      </div>
32208    </div>
32209
32210    <!-- Scan Profiles -->
32211    <div class="section">
32212      <h2 class="section-title">Scan Profiles</h2>
32213
32214      <div class="ep-card">
32215        <div class="ep-header">
32216          <span class="method get">GET</span>
32217          <span class="ep-path">/api/scan-profiles</span>
32218          <span class="auth-badge protected">Protected</span>
32219          <span class="ep-desc">List saved scan profiles</span>
32220          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32221        </div>
32222        <div class="ep-body">
32223          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
32224          <details class="schema"><summary>Response schema</summary>
32225<div class="schema-block">{
32226  "profiles": [{
32227    "id":         string,   // UUID
32228    "name":       string,
32229    "created_at": string,   // ISO-8601
32230    "params":     object
32231  }]
32232}</div></details>
32233          <p class="curl-heading">Example</p>
32234          <div class="curl-wrap">
32235            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32236  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32237            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
32238          </div>
32239        </div>
32240      </div>
32241
32242      <div class="ep-card">
32243        <div class="ep-header">
32244          <span class="method post">POST</span>
32245          <span class="ep-path">/api/scan-profiles</span>
32246          <span class="auth-badge protected">Protected</span>
32247          <span class="ep-desc">Save a scan profile</span>
32248          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32249        </div>
32250        <div class="ep-body">
32251          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
32252          <p class="params-heading">Request Body (application/json)</p>
32253          <table class="params">
32254            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32255            <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>
32256            <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>
32257          </table>
32258          <details class="schema"><summary>Response schema</summary>
32259<div class="schema-block">{ "ok": true }</div></details>
32260          <p class="curl-heading">Example</p>
32261          <div class="curl-wrap">
32262            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
32263  -H "Authorization: Bearer $SLOC_API_KEY" \
32264  -H "Content-Type: application/json" \
32265  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
32266  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
32267            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
32268          </div>
32269        </div>
32270      </div>
32271
32272      <div class="ep-card">
32273        <div class="ep-header">
32274          <span class="method delete">DELETE</span>
32275          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
32276          <span class="auth-badge protected">Protected</span>
32277          <span class="ep-desc">Delete a scan profile</span>
32278          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32279        </div>
32280        <div class="ep-body">
32281          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
32282          <p class="params-heading">Path Parameters</p>
32283          <table class="params">
32284            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32285            <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>
32286          </table>
32287          <details class="schema"><summary>Response schema</summary>
32288<div class="schema-block">{ "ok": true }</div></details>
32289          <p class="curl-heading">Example</p>
32290          <div class="curl-wrap">
32291            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
32292  -H "Authorization: Bearer $SLOC_API_KEY" \
32293  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
32294            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
32295          </div>
32296        </div>
32297      </div>
32298    </div>
32299
32300    <!-- Scheduled Scans -->
32301    <div class="section">
32302      <h2 class="section-title">Scheduled Scans</h2>
32303
32304      <div class="ep-card">
32305        <div class="ep-header">
32306          <span class="method get">GET</span>
32307          <span class="ep-path">/api/schedules</span>
32308          <span class="auth-badge protected">Protected</span>
32309          <span class="ep-desc">List configured schedules</span>
32310          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32311        </div>
32312        <div class="ep-body">
32313          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
32314          <p class="curl-heading">Example</p>
32315          <div class="curl-wrap">
32316            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32317  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32318            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
32319          </div>
32320        </div>
32321      </div>
32322
32323      <div class="ep-card">
32324        <div class="ep-header">
32325          <span class="method post">POST</span>
32326          <span class="ep-path">/api/schedules</span>
32327          <span class="auth-badge protected">Protected</span>
32328          <span class="ep-desc">Create a schedule</span>
32329          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32330        </div>
32331        <div class="ep-body">
32332          <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>
32333          <p class="curl-heading">Example</p>
32334          <div class="curl-wrap">
32335            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32336  -H "Authorization: Bearer $SLOC_API_KEY" \
32337  -H "Content-Type: application/json" \
32338  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32339  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32340            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32341          </div>
32342        </div>
32343      </div>
32344
32345      <div class="ep-card">
32346        <div class="ep-header">
32347          <span class="method delete">DELETE</span>
32348          <span class="ep-path">/api/schedules</span>
32349          <span class="auth-badge protected">Protected</span>
32350          <span class="ep-desc">Delete a schedule</span>
32351          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32352        </div>
32353        <div class="ep-body">
32354          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32355          <p class="curl-heading">Example</p>
32356          <div class="curl-wrap">
32357            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32358  -H "Authorization: Bearer $SLOC_API_KEY" \
32359  -H "Content-Type: application/json" \
32360  -d '{"id":"&lt;schedule_id&gt;"}' \
32361  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32362            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32363          </div>
32364        </div>
32365      </div>
32366    </div>
32367
32368    <!-- Git Browser -->
32369    <div class="section">
32370      <h2 class="section-title">Git Browser</h2>
32371
32372      <div class="ep-card">
32373        <div class="ep-header">
32374          <span class="method get">GET</span>
32375          <span class="ep-path">/api/git/refs</span>
32376          <span class="auth-badge protected">Protected</span>
32377          <span class="ep-desc">List git refs for a repository</span>
32378          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32379        </div>
32380        <div class="ep-body">
32381          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32382          <p class="params-heading">Query Parameters</p>
32383          <table class="params">
32384            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32385            <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32386          </table>
32387          <p class="curl-heading">Example</p>
32388          <div class="curl-wrap">
32389            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32390  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
32391            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32392          </div>
32393        </div>
32394      </div>
32395
32396      <div class="ep-card">
32397        <div class="ep-header">
32398          <span class="method get">GET</span>
32399          <span class="ep-path">/api/git/scan-ref</span>
32400          <span class="auth-badge protected">Protected</span>
32401          <span class="ep-desc">SLOC-scan a specific git ref</span>
32402          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32403        </div>
32404        <div class="ep-body">
32405          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32406          <p class="params-heading">Query Parameters</p>
32407          <table class="params">
32408            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32409            <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32410            <tr><td class="pt-name">ref_name</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
32411          </table>
32412          <p class="curl-heading">Example</p>
32413          <div class="curl-wrap">
32414            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32415  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?repo=/path/to/repo&amp;ref_name=main"</pre>
32416            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32417          </div>
32418        </div>
32419      </div>
32420
32421      <div class="ep-card">
32422        <div class="ep-header">
32423          <span class="method get">GET</span>
32424          <span class="ep-path">/api/git/compare-refs</span>
32425          <span class="auth-badge protected">Protected</span>
32426          <span class="ep-desc">Compare SLOC across two git refs</span>
32427          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32428        </div>
32429        <div class="ep-body">
32430          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32431          <p class="params-heading">Query Parameters</p>
32432          <table class="params">
32433            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32434            <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32435            <tr><td class="pt-name">baseline_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
32436            <tr><td class="pt-name">current_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
32437          </table>
32438          <p class="curl-heading">Example</p>
32439          <div class="curl-wrap">
32440            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32441  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?repo=/path/to/repo&amp;baseline_ref=v1.0&amp;current_ref=main"</pre>
32442            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32443          </div>
32444        </div>
32445      </div>
32446    </div>
32447
32448    <!-- Webhooks -->
32449    <div class="section">
32450      <h2 class="section-title">Webhooks</h2>
32451      <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>
32452
32453      <div class="ep-card">
32454        <div class="ep-header">
32455          <span class="method post">POST</span>
32456          <span class="ep-path">/webhooks/github</span>
32457          <span class="auth-badge hmac">HMAC</span>
32458          <span class="ep-desc">GitHub push event receiver</span>
32459          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32460        </div>
32461        <div class="ep-body">
32462          <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>
32463          <p class="params-heading">Required Headers</p>
32464          <table class="params">
32465            <tr><th>Header</th><th>Value</th></tr>
32466            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32467            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32468            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32469          </table>
32470        </div>
32471      </div>
32472
32473      <div class="ep-card">
32474        <div class="ep-header">
32475          <span class="method post">POST</span>
32476          <span class="ep-path">/webhooks/gitlab</span>
32477          <span class="auth-badge hmac">HMAC</span>
32478          <span class="ep-desc">GitLab push event receiver</span>
32479          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32480        </div>
32481        <div class="ep-body">
32482          <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>
32483          <p class="params-heading">Required Headers</p>
32484          <table class="params">
32485            <tr><th>Header</th><th>Value</th></tr>
32486            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32487            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32488            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32489          </table>
32490        </div>
32491      </div>
32492
32493      <div class="ep-card">
32494        <div class="ep-header">
32495          <span class="method post">POST</span>
32496          <span class="ep-path">/webhooks/bitbucket</span>
32497          <span class="auth-badge hmac">HMAC</span>
32498          <span class="ep-desc">Bitbucket push event receiver</span>
32499          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32500        </div>
32501        <div class="ep-body">
32502          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32503          <p class="params-heading">Required Headers</p>
32504          <table class="params">
32505            <tr><th>Header</th><th>Value</th></tr>
32506            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32507            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32508          </table>
32509        </div>
32510      </div>
32511    </div>
32512
32513    <!-- Config -->
32514    <div class="section">
32515      <h2 class="section-title">Config Import / Export</h2>
32516
32517      <div class="ep-card">
32518        <div class="ep-header">
32519          <span class="method get">GET</span>
32520          <span class="ep-path">/export-config</span>
32521          <span class="auth-badge protected">Protected</span>
32522          <span class="ep-desc">Export server configuration as JSON</span>
32523          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32524        </div>
32525        <div class="ep-body">
32526          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32527          <p class="curl-heading">Example</p>
32528          <div class="curl-wrap">
32529            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32530  -o config.json \
32531  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32532            <button class="curl-copy-btn" data-target="c-export">Copy</button>
32533          </div>
32534        </div>
32535      </div>
32536
32537      <div class="ep-card">
32538        <div class="ep-header">
32539          <span class="method post">POST</span>
32540          <span class="ep-path">/import-config</span>
32541          <span class="auth-badge protected">Protected</span>
32542          <span class="ep-desc">Import server configuration</span>
32543          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32544        </div>
32545        <div class="ep-body">
32546          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32547          <p class="curl-heading">Example</p>
32548          <div class="curl-wrap">
32549            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32550  -H "Authorization: Bearer $SLOC_API_KEY" \
32551  -H "Content-Type: application/json" \
32552  -d @config.json \
32553  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32554            <button class="curl-copy-btn" data-target="c-import">Copy</button>
32555          </div>
32556        </div>
32557      </div>
32558    </div>
32559
32560    <!-- CI Ingest -->
32561    <div class="section">
32562      <h2 class="section-title">CI Ingest</h2>
32563
32564      <div class="ep-card">
32565        <div class="ep-header">
32566          <span class="method post">POST</span>
32567          <span class="ep-path">/api/ingest</span>
32568          <span class="auth-badge protected">Protected</span>
32569          <span class="ep-desc">Push a pre-computed scan result from CI</span>
32570          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32571        </div>
32572        <div class="ep-body">
32573          <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>
32574          <p class="params-heading">Query Parameters</p>
32575          <table class="params">
32576            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32577            <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>
32578          </table>
32579          <p class="params-heading">Request Body (application/json)</p>
32580          <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>
32581          <details class="schema"><summary>Response schema</summary>
32582<div class="schema-block">// 201 Created
32583{
32584  "run_id":   string,  // UUID of the ingested run
32585  "view_url": string   // relative URL to the report page
32586}</div></details>
32587          <p class="curl-heading">Example</p>
32588          <div class="curl-wrap">
32589            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32590  -H "Authorization: Bearer $SLOC_API_KEY" \
32591  -H "Content-Type: application/json" \
32592  -d @result.json \
32593  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32594            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32595          </div>
32596        </div>
32597      </div>
32598    </div>
32599
32600    <!-- Artifact Download -->
32601    <div class="section">
32602      <h2 class="section-title">Artifact Download</h2>
32603
32604      <div class="ep-card">
32605        <div class="ep-header">
32606          <span class="method get">GET</span>
32607          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32608          <span class="auth-badge protected">Protected</span>
32609          <span class="ep-desc">Download or view a scan artifact</span>
32610          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32611        </div>
32612        <div class="ep-body">
32613          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32614          <p class="params-heading">Path Parameters</p>
32615          <table class="params">
32616            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32617            <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>
32618            <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>
32619          </table>
32620          <p class="params-heading">Query Parameters</p>
32621          <table class="params">
32622            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32623            <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>
32624          </table>
32625          <p class="curl-heading">Example — download JSON result</p>
32626          <div class="curl-wrap">
32627            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32628  -o result.json \
32629  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
32630            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32631          </div>
32632        </div>
32633      </div>
32634    </div>
32635
32636    <!-- Embed Widget -->
32637    <div class="section">
32638      <h2 class="section-title">Embed Widget</h2>
32639
32640      <div class="ep-card">
32641        <div class="ep-header">
32642          <span class="method get">GET</span>
32643          <span class="ep-path">/embed/summary</span>
32644          <span class="auth-badge protected">Protected</span>
32645          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32646          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32647        </div>
32648        <div class="ep-body">
32649          <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>
32650          <p class="params-heading">Query Parameters</p>
32651          <table class="params">
32652            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32653            <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>
32654            <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>
32655          </table>
32656          <p class="curl-heading">Example</p>
32657          <div class="curl-wrap">
32658            <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"
32659        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
32660            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32661          </div>
32662        </div>
32663      </div>
32664    </div>
32665
32666    <!-- Confluence Integration -->
32667    <div class="section">
32668      <h2 class="section-title">Confluence Integration</h2>
32669
32670      <div class="ep-card">
32671        <div class="ep-header">
32672          <span class="method get">GET</span>
32673          <span class="ep-path">/api/confluence/config</span>
32674          <span class="auth-badge protected">Protected</span>
32675          <span class="ep-desc">Get current Confluence configuration</span>
32676          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32677        </div>
32678        <div class="ep-body">
32679          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32680          <details class="schema"><summary>Response schema</summary>
32681<div class="schema-block">{
32682  "configured":     boolean,
32683  "tier":           "cloud" | "server",
32684  "base_url":       string,
32685  "username":       string,
32686  "api_token_set":  boolean,
32687  "space_key":      string,
32688  "parent_page_id": string | null,
32689  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
32690}</div></details>
32691          <p class="curl-heading">Example</p>
32692          <div class="curl-wrap">
32693            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32694  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32695            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32696          </div>
32697        </div>
32698      </div>
32699
32700      <div class="ep-card">
32701        <div class="ep-header">
32702          <span class="method post">POST</span>
32703          <span class="ep-path">/api/confluence/config</span>
32704          <span class="auth-badge protected">Protected</span>
32705          <span class="ep-desc">Save Confluence configuration</span>
32706          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32707        </div>
32708        <div class="ep-body">
32709          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32710          <p class="params-heading">Request Body (application/json)</p>
32711          <table class="params">
32712            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32713            <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>
32714            <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>
32715            <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>
32716            <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>
32717            <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>
32718            <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>
32719            <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>
32720          </table>
32721          <details class="schema"><summary>Response schema</summary>
32722<div class="schema-block">{ "ok": true }</div></details>
32723          <p class="curl-heading">Example</p>
32724          <div class="curl-wrap">
32725            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32726  -H "Authorization: Bearer $SLOC_API_KEY" \
32727  -H "Content-Type: application/json" \
32728  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32729  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32730            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32731          </div>
32732        </div>
32733      </div>
32734
32735      <div class="ep-card">
32736        <div class="ep-header">
32737          <span class="method post">POST</span>
32738          <span class="ep-path">/api/confluence/test</span>
32739          <span class="auth-badge protected">Protected</span>
32740          <span class="ep-desc">Test Confluence connection</span>
32741          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32742        </div>
32743        <div class="ep-body">
32744          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32745          <details class="schema"><summary>Response schema</summary>
32746<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32747          <p class="curl-heading">Example</p>
32748          <div class="curl-wrap">
32749            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32750  -H "Authorization: Bearer $SLOC_API_KEY" \
32751  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32752            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32753          </div>
32754        </div>
32755      </div>
32756
32757      <div class="ep-card">
32758        <div class="ep-header">
32759          <span class="method post">POST</span>
32760          <span class="ep-path">/api/confluence/post</span>
32761          <span class="auth-badge protected">Protected</span>
32762          <span class="ep-desc">Publish a scan report to Confluence</span>
32763          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32764        </div>
32765        <div class="ep-body">
32766          <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>
32767          <p class="params-heading">Request Body (application/json)</p>
32768          <table class="params">
32769            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32770            <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>
32771            <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>
32772            <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>
32773          </table>
32774          <details class="schema"><summary>Response schema</summary>
32775<div class="schema-block">// 200 OK
32776{ "ok": true, "page_id": string }
32777
32778// 400 / 502 on error
32779{ "ok": false, "error": string }</div></details>
32780          <p class="curl-heading">Example</p>
32781          <div class="curl-wrap">
32782            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32783  -H "Authorization: Bearer $SLOC_API_KEY" \
32784  -H "Content-Type: application/json" \
32785  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
32786  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32787            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32788          </div>
32789        </div>
32790      </div>
32791
32792      <div class="ep-card">
32793        <div class="ep-header">
32794          <span class="method get">GET</span>
32795          <span class="ep-path">/api/confluence/wiki-markup</span>
32796          <span class="auth-badge protected">Protected</span>
32797          <span class="ep-desc">Get Confluence wiki markup for a run</span>
32798          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32799        </div>
32800        <div class="ep-body">
32801          <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>
32802          <p class="params-heading">Query Parameters</p>
32803          <table class="params">
32804            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32805            <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>
32806          </table>
32807          <p class="curl-heading">Example</p>
32808          <div class="curl-wrap">
32809            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32810  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
32811            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32812          </div>
32813        </div>
32814      </div>
32815    </div>
32816
32817    <!-- Authentication -->
32818    <div class="section">
32819      <h2 class="section-title">Authentication</h2>
32820      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32821
32822      <div class="ep-card">
32823        <div class="ep-header">
32824          <span class="method get">GET</span>
32825          <span class="ep-path">/auth/login</span>
32826          <span class="auth-badge public">Public</span>
32827          <span class="ep-desc">Login page</span>
32828          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32829        </div>
32830        <div class="ep-body">
32831          <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>
32832          <p class="params-heading">Query Parameters</p>
32833          <table class="params">
32834            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32835            <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>
32836            <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>
32837          </table>
32838        </div>
32839      </div>
32840
32841      <div class="ep-card">
32842        <div class="ep-header">
32843          <span class="method post">POST</span>
32844          <span class="ep-path">/auth/login</span>
32845          <span class="auth-badge public">Public</span>
32846          <span class="ep-desc">Submit credentials and get a session cookie</span>
32847          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32848        </div>
32849        <div class="ep-body">
32850          <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>
32851          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32852          <table class="params">
32853            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32854            <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>
32855            <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>
32856          </table>
32857          <p class="curl-heading">Example</p>
32858          <div class="curl-wrap">
32859            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32860  -d "key=$SLOC_API_KEY&amp;next=/" \
32861  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32862            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32863          </div>
32864        </div>
32865      </div>
32866    </div>
32867
32868    <!-- Coverage Suggestion -->
32869    <div class="section">
32870      <h2 class="section-title">Coverage Suggestion</h2>
32871
32872      <div class="ep-card">
32873        <div class="ep-header">
32874          <span class="method get">GET</span>
32875          <span class="ep-path">/api/suggest-coverage</span>
32876          <span class="auth-badge protected">Protected</span>
32877          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32878          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32879        </div>
32880        <div class="ep-body">
32881          <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>
32882          <p class="params-heading">Query Parameters</p>
32883          <table class="params">
32884            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32885            <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>
32886          </table>
32887          <details class="schema"><summary>Response schema</summary>
32888<div class="schema-block">{
32889  "found": string | null,  // absolute path to the coverage file, if detected
32890  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32891  "hint":  string | null   // shell command to generate coverage if not found
32892}</div></details>
32893          <p class="curl-heading">Example</p>
32894          <div class="curl-wrap">
32895            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32896  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32897            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32898          </div>
32899        </div>
32900      </div>
32901    </div>
32902
32903  </div>
32904
32905  <footer class="site-footer">
32906    local code analysis - metrics, history and reports
32907    &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>
32908    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32909    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32910    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32911    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
32912  </footer>
32913
32914  <script nonce="{{ csp_nonce }}">
32915    (function () {
32916      var base = window.location.origin;
32917      document.getElementById('base-url').textContent = base;
32918      document.querySelectorAll('.base-url-slot').forEach(function (el) {
32919        el.textContent = base;
32920      });
32921
32922      document.querySelectorAll('.ep-header').forEach(function (hdr) {
32923        hdr.addEventListener('click', function () {
32924          hdr.closest('.ep-card').classList.toggle('open');
32925        });
32926      });
32927
32928      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32929        btn.addEventListener('click', function () {
32930          var targetId = btn.dataset.target;
32931          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32932          if (!pre) return;
32933          navigator.clipboard.writeText(pre.textContent).then(function () {
32934            btn.textContent = 'Copied!';
32935            btn.classList.add('copied');
32936            setTimeout(function () {
32937              btn.textContent = 'Copy';
32938              btn.classList.remove('copied');
32939            }, 2000);
32940          });
32941        });
32942      });
32943
32944      var storageKey = 'oxide-sloc-theme';
32945      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32946      var themeBtn = document.getElementById('theme-toggle');
32947      if (themeBtn) {
32948        themeBtn.addEventListener('click', function () {
32949          var dark = document.body.classList.toggle('dark-theme');
32950          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32951        });
32952      }
32953      (function() {
32954        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'}];
32955        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);});}
32956        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32957        var btn=document.getElementById('settings-btn');if(!btn)return;
32958        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32959        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>';
32960        document.body.appendChild(m);
32961        var g=document.getElementById('scheme-grid');
32962        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);});
32963        var cl=document.getElementById('settings-close');
32964        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);});})();
32965        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');});
32966        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32967        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32968      })();
32969      (function randomizeWatermarks() {
32970        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32971        if (!wms.length) return;
32972        var placed = [];
32973        function tooClose(top, left) {
32974          for (var i = 0; i < placed.length; i++) {
32975            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32976            if (dt < 16 && dl < 12) return true;
32977          }
32978          return false;
32979        }
32980        function pick(leftBand) {
32981          for (var attempt = 0; attempt < 50; attempt++) {
32982            var top = Math.random() * 88 + 2;
32983            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32984            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32985          }
32986          var top = Math.random() * 88 + 2;
32987          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32988          placed.push([top, left]); return [top, left];
32989        }
32990        var half = Math.floor(wms.length / 2);
32991        wms.forEach(function (img, i) {
32992          var pos = pick(i < half);
32993          var size = Math.floor(Math.random() * 100 + 120);
32994          var rot = (Math.random() * 360).toFixed(1);
32995          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32996          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;
32997        });
32998      })();
32999      (function spawnCodeParticles() {
33000        var container = document.getElementById('code-particles');
33001        if (!container) return;
33002        var snippets = [
33003          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
33004          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
33005          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
33006          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
33007          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
33008        ];
33009        var count = 38;
33010        for (var i = 0; i < count; i++) {
33011          (function(idx) {
33012            var el = document.createElement('span');
33013            el.className = 'code-particle';
33014            el.textContent = snippets[idx % snippets.length];
33015            var left = Math.random() * 94 + 2;
33016            var top = Math.random() * 88 + 6;
33017            var dur = (Math.random() * 10 + 9).toFixed(1);
33018            var delay = (Math.random() * 18).toFixed(1);
33019            var rot = (Math.random() * 26 - 13).toFixed(1);
33020            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
33021            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
33022            container.appendChild(el);
33023          })(i);
33024        }
33025      })();
33026    }());
33027  </script>
33028</body>
33029</html>
33030"##,
33031    ext = "html"
33032)]
33033struct ApiDocsTemplate {
33034    has_api_key: bool,
33035    csp_nonce: String,
33036    version: &'static str,
33037}
33038
33039#[cfg(test)]
33040mod form_config_tests {
33041    use super::*;
33042    use sloc_config::{
33043        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
33044    };
33045
33046    fn blank_form() -> AnalyzeForm {
33047        AnalyzeForm {
33048            path: ".".to_string(),
33049            git_repo: None,
33050            git_ref: None,
33051            mixed_line_policy: None,
33052            python_docstrings_as_comments: None,
33053            generated_file_detection: None,
33054            minified_file_detection: None,
33055            vendor_directory_detection: None,
33056            include_lockfiles: None,
33057            binary_file_behavior: None,
33058            output_dir: None,
33059            report_title: None,
33060            report_header_footer: None,
33061            include_globs: None,
33062            exclude_globs: None,
33063            submodule_breakdown: None,
33064            coverage_file: None,
33065            continuation_line_policy: None,
33066            blank_in_block_comment_policy: None,
33067            count_compiler_directives: None,
33068            style_col_threshold: None,
33069            style_analysis_enabled: None,
33070            style_score_threshold: None,
33071            style_lang_scope: None,
33072            cocomo_mode: None,
33073            complexity_alert: None,
33074            exclude_duplicates: None,
33075            activity_window: None,
33076        }
33077    }
33078
33079    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
33080        let mut cfg = sloc_config::AppConfig::default();
33081        apply_form_to_config(&mut cfg, form);
33082        cfg
33083    }
33084
33085    // ── activity_window (git hotspots — on by default) ──
33086
33087    #[test]
33088    fn extract_long_commit_picks_super_repo_by_short_prefix() {
33089        // A pretty-printed JSON tail containing several submodule git_commit_long
33090        // values plus the super-repo's; the helper must return the one whose hash
33091        // starts with the known short SHA, ignoring the others and any null value.
33092        let dir = tempfile::tempdir().unwrap();
33093        let path = dir.path().join("result.json");
33094        let body = r#"{
33095  "submodules": [
33096    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
33097    { "git_commit_long": null }
33098  ],
33099  "git_commit_short": "4c2cd9b",
33100  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
33101}"#;
33102        std::fs::write(&path, body).unwrap();
33103        assert_eq!(
33104            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
33105            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
33106        );
33107        // No match for an unrelated short SHA, and empty short yields None.
33108        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
33109        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
33110    }
33111
33112    #[test]
33113    fn activity_window_defaults_on_when_field_blank() {
33114        // Blank form field keeps the config default (90 days).
33115        let cfg = apply(&blank_form());
33116        assert_eq!(cfg.analysis.activity_window_days, Some(90));
33117    }
33118
33119    #[test]
33120    fn activity_window_override_sets_days() {
33121        let mut form = blank_form();
33122        form.activity_window = Some("30".to_string());
33123        let cfg = apply(&form);
33124        assert_eq!(cfg.analysis.activity_window_days, Some(30));
33125    }
33126
33127    #[test]
33128    fn activity_window_zero_disables() {
33129        // An explicit 0 from the form disables hotspots (overrides the default-on).
33130        let mut form = blank_form();
33131        form.activity_window = Some("0".to_string());
33132        let cfg = apply(&form);
33133        assert_eq!(cfg.analysis.activity_window_days, Some(0));
33134    }
33135
33136    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
33137
33138    #[test]
33139    fn python_docstrings_false_when_unchecked() {
33140        // Checkbox absent in form data (unchecked) → field must be false.
33141        let cfg = apply(&blank_form());
33142        assert!(
33143            !cfg.analysis.python_docstrings_as_comments,
33144            "absent python_docstrings_as_comments must map to false"
33145        );
33146    }
33147
33148    #[test]
33149    fn python_docstrings_true_when_checked() {
33150        // Browser sends "on" (no value= attr on the checkbox).
33151        let mut form = blank_form();
33152        form.python_docstrings_as_comments = Some("on".to_string());
33153        let cfg = apply(&form);
33154        assert!(cfg.analysis.python_docstrings_as_comments);
33155    }
33156
33157    #[test]
33158    fn python_docstrings_true_for_any_non_none_value() {
33159        // The handler uses .is_some() — any non-None value means "checked".
33160        let mut form = blank_form();
33161        form.python_docstrings_as_comments = Some("true".to_string());
33162        assert!(apply(&form).analysis.python_docstrings_as_comments);
33163    }
33164
33165    // ── submodule_breakdown (checkbox with value="enabled") ──
33166
33167    #[test]
33168    fn submodule_breakdown_false_when_unchecked() {
33169        let cfg = apply(&blank_form());
33170        assert!(
33171            !cfg.discovery.submodule_breakdown,
33172            "absent submodule_breakdown must map to false"
33173        );
33174    }
33175
33176    #[test]
33177    fn submodule_breakdown_true_when_value_enabled() {
33178        let mut form = blank_form();
33179        form.submodule_breakdown = Some("enabled".to_string());
33180        assert!(apply(&form).discovery.submodule_breakdown);
33181    }
33182
33183    #[test]
33184    fn submodule_breakdown_false_for_wrong_value() {
33185        // If somehow a value other than "enabled" is sent, it must still be false.
33186        let mut form = blank_form();
33187        form.submodule_breakdown = Some("on".to_string());
33188        assert!(
33189            !apply(&form).discovery.submodule_breakdown,
33190            "submodule_breakdown only becomes true for the exact value 'enabled'"
33191        );
33192    }
33193
33194    // ── generated_file_detection (select: "enabled" | "disabled") ──
33195
33196    #[test]
33197    fn generated_detection_true_when_enabled() {
33198        let mut form = blank_form();
33199        form.generated_file_detection = Some("enabled".to_string());
33200        assert!(apply(&form).analysis.generated_file_detection);
33201    }
33202
33203    #[test]
33204    fn generated_detection_false_when_disabled() {
33205        let mut form = blank_form();
33206        form.generated_file_detection = Some("disabled".to_string());
33207        assert!(!apply(&form).analysis.generated_file_detection);
33208    }
33209
33210    #[test]
33211    fn generated_detection_true_when_absent() {
33212        // None != Some("disabled") → true (safe default)
33213        assert!(
33214            apply(&blank_form()).analysis.generated_file_detection,
33215            "absent field must default to true (detection on)"
33216        );
33217    }
33218
33219    // ── minified_file_detection ──
33220
33221    #[test]
33222    fn minified_detection_false_when_disabled() {
33223        let mut form = blank_form();
33224        form.minified_file_detection = Some("disabled".to_string());
33225        assert!(!apply(&form).analysis.minified_file_detection);
33226    }
33227
33228    #[test]
33229    fn minified_detection_true_when_enabled() {
33230        let mut form = blank_form();
33231        form.minified_file_detection = Some("enabled".to_string());
33232        assert!(apply(&form).analysis.minified_file_detection);
33233    }
33234
33235    #[test]
33236    fn minified_detection_true_when_absent() {
33237        assert!(apply(&blank_form()).analysis.minified_file_detection);
33238    }
33239
33240    // ── vendor_directory_detection ──
33241
33242    #[test]
33243    fn vendor_detection_false_when_disabled() {
33244        let mut form = blank_form();
33245        form.vendor_directory_detection = Some("disabled".to_string());
33246        assert!(!apply(&form).analysis.vendor_directory_detection);
33247    }
33248
33249    #[test]
33250    fn vendor_detection_true_when_enabled() {
33251        let mut form = blank_form();
33252        form.vendor_directory_detection = Some("enabled".to_string());
33253        assert!(apply(&form).analysis.vendor_directory_detection);
33254    }
33255
33256    #[test]
33257    fn vendor_detection_true_when_absent() {
33258        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
33259    }
33260
33261    // ── include_lockfiles (select: "disabled" default | "enabled") ──
33262
33263    #[test]
33264    fn lockfiles_false_when_absent() {
33265        // None == Some("enabled") is false → lockfiles off (correct safe default)
33266        assert!(!apply(&blank_form()).analysis.include_lockfiles);
33267    }
33268
33269    #[test]
33270    fn lockfiles_false_when_disabled() {
33271        let mut form = blank_form();
33272        form.include_lockfiles = Some("disabled".to_string());
33273        assert!(!apply(&form).analysis.include_lockfiles);
33274    }
33275
33276    #[test]
33277    fn lockfiles_true_when_enabled() {
33278        let mut form = blank_form();
33279        form.include_lockfiles = Some("enabled".to_string());
33280        assert!(apply(&form).analysis.include_lockfiles);
33281    }
33282
33283    // ── count_compiler_directives ──
33284
33285    #[test]
33286    fn compiler_directives_true_when_absent() {
33287        assert!(
33288            apply(&blank_form()).analysis.count_compiler_directives,
33289            "absent count_compiler_directives must default to true"
33290        );
33291    }
33292
33293    #[test]
33294    fn compiler_directives_true_when_enabled() {
33295        let mut form = blank_form();
33296        form.count_compiler_directives = Some("enabled".to_string());
33297        assert!(apply(&form).analysis.count_compiler_directives);
33298    }
33299
33300    #[test]
33301    fn compiler_directives_false_when_disabled() {
33302        let mut form = blank_form();
33303        form.count_compiler_directives = Some("disabled".to_string());
33304        assert!(!apply(&form).analysis.count_compiler_directives);
33305    }
33306
33307    // ── mixed_line_policy (enum select) ──
33308
33309    #[test]
33310    fn mixed_policy_unchanged_when_absent() {
33311        // None → if-let does nothing → stays at config default (CodeOnly)
33312        assert_eq!(
33313            apply(&blank_form()).analysis.mixed_line_policy,
33314            MixedLinePolicy::CodeOnly
33315        );
33316    }
33317
33318    #[test]
33319    fn mixed_policy_code_only() {
33320        let mut form = blank_form();
33321        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
33322        assert_eq!(
33323            apply(&form).analysis.mixed_line_policy,
33324            MixedLinePolicy::CodeOnly
33325        );
33326    }
33327
33328    #[test]
33329    fn mixed_policy_code_and_comment() {
33330        let mut form = blank_form();
33331        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33332        assert_eq!(
33333            apply(&form).analysis.mixed_line_policy,
33334            MixedLinePolicy::CodeAndComment
33335        );
33336    }
33337
33338    #[test]
33339    fn mixed_policy_comment_only() {
33340        let mut form = blank_form();
33341        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33342        assert_eq!(
33343            apply(&form).analysis.mixed_line_policy,
33344            MixedLinePolicy::CommentOnly
33345        );
33346    }
33347
33348    #[test]
33349    fn mixed_policy_separate_mixed_category() {
33350        let mut form = blank_form();
33351        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33352        assert_eq!(
33353            apply(&form).analysis.mixed_line_policy,
33354            MixedLinePolicy::SeparateMixedCategory
33355        );
33356    }
33357
33358    // ── binary_file_behavior (enum select) ──
33359
33360    #[test]
33361    fn binary_behavior_skip_when_absent() {
33362        assert_eq!(
33363            apply(&blank_form()).analysis.binary_file_behavior,
33364            BinaryFileBehavior::Skip
33365        );
33366    }
33367
33368    #[test]
33369    fn binary_behavior_skip() {
33370        let mut form = blank_form();
33371        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33372        assert_eq!(
33373            apply(&form).analysis.binary_file_behavior,
33374            BinaryFileBehavior::Skip
33375        );
33376    }
33377
33378    #[test]
33379    fn binary_behavior_fail() {
33380        let mut form = blank_form();
33381        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33382        assert_eq!(
33383            apply(&form).analysis.binary_file_behavior,
33384            BinaryFileBehavior::Fail
33385        );
33386    }
33387
33388    // ── continuation_line_policy (enum select) ──
33389
33390    #[test]
33391    fn continuation_policy_each_physical_when_absent() {
33392        assert_eq!(
33393            apply(&blank_form()).analysis.continuation_line_policy,
33394            ContinuationLinePolicy::EachPhysicalLine
33395        );
33396    }
33397
33398    #[test]
33399    fn continuation_policy_collapse_to_logical() {
33400        let mut form = blank_form();
33401        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33402        assert_eq!(
33403            apply(&form).analysis.continuation_line_policy,
33404            ContinuationLinePolicy::CollapseToLogical
33405        );
33406    }
33407
33408    // ── blank_in_block_comment_policy (enum select) ──
33409
33410    #[test]
33411    fn blank_in_block_comment_count_as_comment_when_absent() {
33412        assert_eq!(
33413            apply(&blank_form()).analysis.blank_in_block_comment_policy,
33414            BlankInBlockCommentPolicy::CountAsComment
33415        );
33416    }
33417
33418    #[test]
33419    fn blank_in_block_comment_count_as_blank() {
33420        let mut form = blank_form();
33421        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33422        assert_eq!(
33423            apply(&form).analysis.blank_in_block_comment_policy,
33424            BlankInBlockCommentPolicy::CountAsBlank
33425        );
33426    }
33427
33428    // ── style_col_threshold ──
33429
33430    #[test]
33431    fn style_threshold_80() {
33432        let mut form = blank_form();
33433        form.style_col_threshold = Some("80".to_string());
33434        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33435    }
33436
33437    #[test]
33438    fn style_threshold_100() {
33439        let mut form = blank_form();
33440        form.style_col_threshold = Some("100".to_string());
33441        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33442    }
33443
33444    #[test]
33445    fn style_threshold_120() {
33446        let mut form = blank_form();
33447        form.style_col_threshold = Some("120".to_string());
33448        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33449    }
33450
33451    #[test]
33452    fn style_threshold_invalid_value_leaves_default() {
33453        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
33454        let mut cfg = sloc_config::AppConfig::default();
33455        let mut form = blank_form();
33456        form.style_col_threshold = Some("42".to_string());
33457        apply_form_to_config(&mut cfg, &form);
33458        assert_eq!(
33459            cfg.analysis.style_col_threshold, 80,
33460            "invalid threshold must not change config"
33461        );
33462    }
33463
33464    #[test]
33465    fn style_threshold_non_numeric_leaves_default() {
33466        let mut cfg = sloc_config::AppConfig::default();
33467        let mut form = blank_form();
33468        form.style_col_threshold = Some("large".to_string());
33469        apply_form_to_config(&mut cfg, &form);
33470        assert_eq!(cfg.analysis.style_col_threshold, 80);
33471    }
33472
33473    #[test]
33474    fn style_threshold_zero_leaves_default() {
33475        let mut cfg = sloc_config::AppConfig::default();
33476        let mut form = blank_form();
33477        form.style_col_threshold = Some("0".to_string());
33478        apply_form_to_config(&mut cfg, &form);
33479        assert_eq!(cfg.analysis.style_col_threshold, 80);
33480    }
33481
33482    #[test]
33483    fn style_threshold_absent_leaves_default() {
33484        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33485    }
33486
33487    // ── style_score_threshold ──
33488
33489    #[test]
33490    fn style_score_threshold_zero_when_absent() {
33491        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33492    }
33493
33494    #[test]
33495    fn style_score_threshold_set_to_valid_value() {
33496        let mut form = blank_form();
33497        form.style_score_threshold = Some("70".to_string());
33498        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33499    }
33500
33501    #[test]
33502    fn style_score_threshold_clamps_to_100_when_over() {
33503        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
33504        let mut form = blank_form();
33505        form.style_score_threshold = Some("200".to_string());
33506        assert_eq!(
33507            apply(&form).analysis.style_score_threshold,
33508            100,
33509            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33510        );
33511    }
33512
33513    // ── coverage_file ──
33514
33515    #[test]
33516    fn coverage_file_none_when_absent() {
33517        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33518    }
33519
33520    #[test]
33521    fn coverage_file_none_when_whitespace_only() {
33522        let mut form = blank_form();
33523        form.coverage_file = Some("   ".to_string());
33524        assert!(
33525            apply(&form).analysis.coverage_file.is_none(),
33526            "whitespace-only coverage_file must be treated as None"
33527        );
33528    }
33529
33530    #[test]
33531    fn coverage_file_set_when_non_empty() {
33532        let mut form = blank_form();
33533        form.coverage_file = Some("coverage/lcov.info".to_string());
33534        assert_eq!(
33535            apply(&form).analysis.coverage_file,
33536            Some(std::path::PathBuf::from("coverage/lcov.info"))
33537        );
33538    }
33539
33540    #[test]
33541    fn coverage_file_trims_whitespace() {
33542        let mut form = blank_form();
33543        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
33544        assert_eq!(
33545            apply(&form).analysis.coverage_file,
33546            Some(std::path::PathBuf::from("coverage/lcov.info"))
33547        );
33548    }
33549
33550    // ── report_title ──
33551
33552    #[test]
33553    fn report_title_unchanged_when_absent() {
33554        let original = sloc_config::AppConfig::default().reporting.report_title;
33555        assert_eq!(apply(&blank_form()).reporting.report_title, original);
33556    }
33557
33558    #[test]
33559    fn report_title_unchanged_when_whitespace_only() {
33560        let original = sloc_config::AppConfig::default().reporting.report_title;
33561        let mut form = blank_form();
33562        form.report_title = Some("   ".to_string());
33563        assert_eq!(
33564            apply(&form).reporting.report_title,
33565            original,
33566            "whitespace-only title must not overwrite the default"
33567        );
33568    }
33569
33570    #[test]
33571    fn report_title_updated_and_trimmed() {
33572        let mut form = blank_form();
33573        form.report_title = Some("  My Project  ".to_string());
33574        assert_eq!(apply(&form).reporting.report_title, "My Project");
33575    }
33576
33577    // ── report_header_footer ──
33578
33579    #[test]
33580    fn header_footer_none_when_absent() {
33581        assert!(
33582            apply(&blank_form())
33583                .reporting
33584                .report_header_footer
33585                .is_none()
33586        );
33587    }
33588
33589    #[test]
33590    fn header_footer_none_when_whitespace_only() {
33591        let mut form = blank_form();
33592        form.report_header_footer = Some("  ".to_string());
33593        assert!(apply(&form).reporting.report_header_footer.is_none());
33594    }
33595
33596    #[test]
33597    fn header_footer_set_and_trimmed() {
33598        let mut form = blank_form();
33599        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
33600        assert_eq!(
33601            apply(&form).reporting.report_header_footer,
33602            Some("Confidential — Internal Use".to_string())
33603        );
33604    }
33605
33606    // ── include_globs / exclude_globs ──
33607
33608    #[test]
33609    fn include_globs_empty_when_absent() {
33610        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33611    }
33612
33613    #[test]
33614    fn include_globs_newline_separated() {
33615        let mut form = blank_form();
33616        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33617        assert_eq!(
33618            apply(&form).discovery.include_globs,
33619            vec!["src/**/*.rs", "tests/**/*.rs"]
33620        );
33621    }
33622
33623    #[test]
33624    fn exclude_globs_comma_separated() {
33625        let mut form = blank_form();
33626        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33627        assert_eq!(
33628            apply(&form).discovery.exclude_globs,
33629            vec!["vendor/**", "node_modules/**"]
33630        );
33631    }
33632
33633    #[test]
33634    fn globs_mixed_separators() {
33635        let mut form = blank_form();
33636        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33637        assert_eq!(
33638            apply(&form).discovery.exclude_globs,
33639            vec!["a/**", "b/**", "c/**"]
33640        );
33641    }
33642
33643    // ── split_patterns unit tests ──
33644
33645    #[test]
33646    fn split_patterns_none_is_empty() {
33647        assert!(split_patterns(None).is_empty());
33648    }
33649
33650    #[test]
33651    fn split_patterns_empty_string_is_empty() {
33652        assert!(split_patterns(Some("")).is_empty());
33653    }
33654
33655    #[test]
33656    fn split_patterns_whitespace_only_is_empty() {
33657        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
33658    }
33659
33660    #[test]
33661    fn split_patterns_newlines() {
33662        assert_eq!(
33663            split_patterns(Some("a/**\nb/**\nc/**")),
33664            vec!["a/**", "b/**", "c/**"]
33665        );
33666    }
33667
33668    #[test]
33669    fn split_patterns_commas() {
33670        assert_eq!(
33671            split_patterns(Some("a/**,b/**,c/**")),
33672            vec!["a/**", "b/**", "c/**"]
33673        );
33674    }
33675
33676    #[test]
33677    fn split_patterns_mixed() {
33678        assert_eq!(
33679            split_patterns(Some("a/**\nb/**,c/**")),
33680            vec!["a/**", "b/**", "c/**"]
33681        );
33682    }
33683
33684    #[test]
33685    fn split_patterns_trims_whitespace() {
33686        assert_eq!(
33687            split_patterns(Some("  a/**  \n  b/**  ")),
33688            vec!["a/**", "b/**"]
33689        );
33690    }
33691
33692    #[test]
33693    fn split_patterns_filters_empty_entries() {
33694        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33695    }
33696
33697    #[test]
33698    fn split_patterns_single_entry() {
33699        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33700    }
33701}
33702
33703#[cfg(test)]
33704mod utility_tests {
33705    use super::*;
33706    use std::net::IpAddr;
33707    use std::time::Duration;
33708
33709    // ── sanitize_project_label ────────────────────────────────────────────────
33710
33711    #[test]
33712    fn sanitize_simple_name() {
33713        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33714    }
33715
33716    #[test]
33717    fn sanitize_uppercased_lowercased() {
33718        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33719    }
33720
33721    #[test]
33722    fn sanitize_path_extracts_filename() {
33723        assert_eq!(
33724            sanitize_project_label("/home/user/my-project"),
33725            "my-project"
33726        );
33727    }
33728
33729    #[test]
33730    fn sanitize_path_uses_last_component() {
33731        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33732    }
33733
33734    #[test]
33735    fn sanitize_spaces_become_hyphens() {
33736        assert_eq!(sanitize_project_label("my project"), "my-project");
33737    }
33738
33739    #[test]
33740    fn sanitize_non_ascii_become_hyphens() {
33741        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33742    }
33743
33744    #[test]
33745    fn sanitize_all_special_chars_gives_project() {
33746        assert_eq!(sanitize_project_label("!@#$%^"), "project");
33747    }
33748
33749    #[test]
33750    fn sanitize_empty_string_gives_project() {
33751        assert_eq!(sanitize_project_label(""), "project");
33752    }
33753
33754    #[test]
33755    fn sanitize_leading_trailing_hyphens_stripped() {
33756        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33757    }
33758
33759    #[test]
33760    fn sanitize_alphanumeric_preserved() {
33761        assert_eq!(sanitize_project_label("repo123"), "repo123");
33762    }
33763
33764    #[test]
33765    fn sanitize_dots_become_hyphens() {
33766        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33767    }
33768
33769    #[test]
33770    fn sanitize_mixed_slashes_uses_filename() {
33771        // The Windows path separator — on all platforms Path::file_name still works
33772        assert_eq!(sanitize_project_label("project-name"), "project-name");
33773    }
33774
33775    // ── IpRateLimiter ─────────────────────────────────────────────────────────
33776
33777    #[test]
33778    fn rate_limiter_allows_first_request() {
33779        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33780        let ip: IpAddr = "127.0.0.1".parse().unwrap();
33781        assert!(rl.is_allowed(ip));
33782    }
33783
33784    #[test]
33785    fn rate_limiter_blocks_after_limit_reached() {
33786        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33787        let ip: IpAddr = "10.0.0.1".parse().unwrap();
33788        assert!(rl.is_allowed(ip));
33789        assert!(rl.is_allowed(ip));
33790        assert!(rl.is_allowed(ip));
33791        assert!(!rl.is_allowed(ip), "4th request must be blocked");
33792    }
33793
33794    #[test]
33795    fn rate_limiter_allows_requests_up_to_limit() {
33796        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33797        let ip: IpAddr = "10.0.0.2".parse().unwrap();
33798        for _ in 0..5 {
33799            assert!(rl.is_allowed(ip));
33800        }
33801        assert!(!rl.is_allowed(ip), "6th request must be blocked");
33802    }
33803
33804    #[test]
33805    fn rate_limiter_different_ips_are_independent() {
33806        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33807        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33808        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33809        assert!(rl.is_allowed(ip1));
33810        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33811        assert!(rl.is_allowed(ip2), "ip2 must be independent");
33812    }
33813
33814    #[test]
33815    fn rate_limiter_auth_failure_not_locked_below_threshold() {
33816        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33817        let ip: IpAddr = "10.0.0.3".parse().unwrap();
33818        rl.record_auth_failure(ip);
33819        rl.record_auth_failure(ip);
33820        assert!(
33821            !rl.is_auth_locked_out(ip),
33822            "not locked at 2 failures when threshold is 3"
33823        );
33824    }
33825
33826    #[test]
33827    fn rate_limiter_auth_failure_locked_at_threshold() {
33828        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33829        let ip: IpAddr = "10.0.0.4".parse().unwrap();
33830        rl.record_auth_failure(ip);
33831        rl.record_auth_failure(ip);
33832        rl.record_auth_failure(ip);
33833        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33834    }
33835
33836    #[test]
33837    fn rate_limiter_auth_failure_different_ips_independent() {
33838        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33839        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33840        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33841        rl.record_auth_failure(ip1);
33842        rl.record_auth_failure(ip1);
33843        assert!(rl.is_auth_locked_out(ip1));
33844        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33845    }
33846
33847    #[test]
33848    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33849        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33850        let ip: IpAddr = "127.0.0.2".parse().unwrap();
33851        for _ in 0..100 {
33852            assert!(rl.is_allowed(ip));
33853        }
33854    }
33855
33856    // ── strip_unc_prefix ──────────────────────────────────────────────────────
33857
33858    #[test]
33859    fn strip_unc_plain_path_unchanged() {
33860        let p = PathBuf::from("C:\\Users\\user\\project");
33861        let result = strip_unc_prefix(p.clone());
33862        assert_eq!(result, p);
33863    }
33864
33865    #[test]
33866    fn strip_unc_with_drive_prefix_stripped() {
33867        let p = PathBuf::from(r"\\?\C:\Users\user\project");
33868        let result = strip_unc_prefix(p);
33869        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33870    }
33871
33872    #[test]
33873    fn strip_unc_with_network_prefix_stripped() {
33874        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33875        let result = strip_unc_prefix(p);
33876        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33877    }
33878
33879    #[test]
33880    fn strip_unc_linux_path_unchanged() {
33881        let p = PathBuf::from("/home/user/project");
33882        let result = strip_unc_prefix(p.clone());
33883        assert_eq!(result, p);
33884    }
33885
33886    // ── remote_to_commit_url ──────────────────────────────────────────────────
33887
33888    #[test]
33889    fn remote_to_commit_url_github_https() {
33890        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33891        assert_eq!(
33892            url,
33893            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33894        );
33895    }
33896
33897    #[test]
33898    fn remote_to_commit_url_github_ssh() {
33899        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33900        assert_eq!(
33901            url,
33902            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33903        );
33904    }
33905
33906    #[test]
33907    fn remote_to_commit_url_gitlab_uses_dash_commit() {
33908        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33909        assert_eq!(
33910            url,
33911            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33912        );
33913    }
33914
33915    #[test]
33916    fn remote_to_commit_url_bitbucket_uses_commits() {
33917        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33918        assert_eq!(
33919            url,
33920            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33921        );
33922    }
33923
33924    #[test]
33925    fn remote_to_commit_url_unknown_scheme_returns_none() {
33926        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33927        assert!(url.is_none());
33928    }
33929
33930    #[test]
33931    fn remote_to_commit_url_ssh_gitlab() {
33932        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33933        assert!(url.is_some());
33934        let u = url.unwrap();
33935        assert!(
33936            u.contains("/-/commit/sha123"),
33937            "gitlab ssh must use /-/commit/"
33938        );
33939    }
33940
33941    // ── git_clone_dest ────────────────────────────────────────────────────────
33942
33943    #[test]
33944    fn git_clone_dest_github_url_produces_safe_name() {
33945        let dir = PathBuf::from("/tmp/clones");
33946        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33947        let name = dest.file_name().unwrap().to_string_lossy();
33948        assert!(!name.is_empty());
33949        assert!(
33950            name.chars()
33951                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33952            "clone dest must only contain safe chars, got: {name}"
33953        );
33954    }
33955
33956    #[test]
33957    fn git_clone_dest_is_inside_clones_dir() {
33958        let dir = PathBuf::from("/tmp/clones");
33959        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33960        assert!(
33961            dest.starts_with(&dir),
33962            "clone dest must be inside clones_dir"
33963        );
33964    }
33965
33966    #[test]
33967    fn git_clone_dest_truncates_to_80_chars_max() {
33968        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33969        let dir = PathBuf::from("/tmp/clones");
33970        let dest = git_clone_dest(&long_url, &dir);
33971        let name = dest.file_name().unwrap().to_string_lossy();
33972        assert!(
33973            name.len() <= 80,
33974            "clone dest name must be at most 80 chars, got {} chars: {name}",
33975            name.len()
33976        );
33977    }
33978
33979    #[test]
33980    fn git_clone_dest_special_chars_replaced_with_underscore() {
33981        let dir = PathBuf::from("/tmp/clones");
33982        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33983        let name = dest.file_name().unwrap().to_string_lossy();
33984        assert!(
33985            !name.contains('@') && !name.contains(':') && !name.contains('/'),
33986            "special chars must be replaced in clone dest, got: {name}"
33987        );
33988    }
33989
33990    #[test]
33991    fn git_clone_dest_different_urls_differ() {
33992        let dir = PathBuf::from("/tmp/clones");
33993        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33994        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33995        assert_ne!(
33996            a, b,
33997            "different repos must produce different clone dest names"
33998        );
33999    }
34000
34001    #[test]
34002    fn git_clone_dest_same_url_same_result() {
34003        let dir = PathBuf::from("/tmp/clones");
34004        let url = "https://github.com/owner/repo.git";
34005        assert_eq!(
34006            git_clone_dest(url, &dir),
34007            git_clone_dest(url, &dir),
34008            "same URL must always give same clone dest"
34009        );
34010    }
34011
34012    // ── fmt_delta ─────────────────────────────────────────────────────────────
34013
34014    #[test]
34015    fn fmt_delta_positive_has_plus_prefix() {
34016        assert_eq!(fmt_delta(5), "+5");
34017    }
34018
34019    #[test]
34020    fn fmt_delta_negative_no_plus_prefix() {
34021        assert_eq!(fmt_delta(-3), "-3");
34022    }
34023
34024    #[test]
34025    fn fmt_delta_zero() {
34026        assert_eq!(fmt_delta(0), "0");
34027    }
34028
34029    // ── delta_class ───────────────────────────────────────────────────────────
34030
34031    #[test]
34032    fn delta_class_positive_is_pos() {
34033        assert_eq!(delta_class(1), "pos");
34034    }
34035
34036    #[test]
34037    fn delta_class_negative_is_neg() {
34038        assert_eq!(delta_class(-1), "neg");
34039    }
34040
34041    #[test]
34042    fn delta_class_zero_is_zero_class() {
34043        assert_eq!(delta_class(0), "zero");
34044    }
34045
34046    // ── fmt_pct ───────────────────────────────────────────────────────────────
34047
34048    #[test]
34049    fn fmt_pct_zero_baseline_returns_em_dash() {
34050        assert_eq!(fmt_pct(100, 0), "\u{2014}");
34051    }
34052
34053    #[test]
34054    fn fmt_pct_positive_delta_has_plus_sign() {
34055        let result = fmt_pct(10, 100);
34056        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
34057    }
34058
34059    #[test]
34060    fn fmt_pct_negative_delta_no_plus_sign() {
34061        let result = fmt_pct(-10, 100);
34062        assert!(!result.starts_with('+'), "unexpected + in: {result}");
34063        assert!(result.contains('%'));
34064    }
34065
34066    #[test]
34067    fn fmt_pct_near_zero_returns_pm_zero() {
34068        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
34069    }
34070
34071    // ── summary_delta ─────────────────────────────────────────────────────────
34072
34073    #[test]
34074    fn summary_delta_no_prev_returns_dash_na() {
34075        let (display, class) = summary_delta(10, None);
34076        assert_eq!(display, "\u{2014}");
34077        assert_eq!(class, "na");
34078    }
34079
34080    #[test]
34081    fn summary_delta_increase_is_positive() {
34082        let (display, class) = summary_delta(15, Some(10));
34083        assert_eq!(display, "+5");
34084        assert_eq!(class, "pos");
34085    }
34086
34087    #[test]
34088    fn summary_delta_decrease_is_negative() {
34089        let (display, class) = summary_delta(5, Some(10));
34090        assert_eq!(display, "-5");
34091        assert_eq!(class, "neg");
34092    }
34093
34094    // ── nth_weekday_of_month ──────────────────────────────────────────────────
34095
34096    #[test]
34097    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
34098        use chrono::Datelike;
34099        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
34100        assert_eq!(d.year(), 2024);
34101        assert_eq!(d.month(), 1);
34102        assert_eq!(d.weekday(), chrono::Weekday::Mon);
34103        assert!(d.day() <= 7);
34104    }
34105
34106    #[test]
34107    fn nth_weekday_second_sunday_march_2024_is_10th() {
34108        use chrono::Datelike;
34109        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
34110        assert_eq!(d.weekday(), chrono::Weekday::Sun);
34111        assert_eq!(d.month(), 3);
34112        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
34113    }
34114
34115    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
34116
34117    #[test]
34118    fn is_pacific_dst_july_is_true() {
34119        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34120        assert!(is_pacific_dst(dt), "July must be PDT");
34121    }
34122
34123    #[test]
34124    fn is_pacific_dst_january_is_false() {
34125        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34126        assert!(!is_pacific_dst(dt), "January must be PST");
34127    }
34128
34129    #[test]
34130    fn fmt_la_time_summer_shows_pdt() {
34131        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
34132        let result = fmt_la_time(dt);
34133        assert!(
34134            result.ends_with("PDT"),
34135            "summer must use PDT, got: {result}"
34136        );
34137    }
34138
34139    #[test]
34140    fn fmt_la_time_winter_shows_pst() {
34141        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
34142        let result = fmt_la_time(dt);
34143        assert!(
34144            result.ends_with("PST"),
34145            "winter must use PST, got: {result}"
34146        );
34147    }
34148
34149    #[test]
34150    fn fmt_la_time_meta_summer_shows_pdt() {
34151        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
34152        let result = fmt_la_time_meta(dt);
34153        assert!(
34154            result.ends_with("PDT"),
34155            "meta summer must use PDT, got: {result}"
34156        );
34157    }
34158
34159    #[test]
34160    fn fmt_la_time_meta_winter_shows_pst() {
34161        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
34162        let result = fmt_la_time_meta(dt);
34163        assert!(
34164            result.ends_with("PST"),
34165            "meta winter must use PST, got: {result}"
34166        );
34167    }
34168
34169    // ── fmt_git_date ──────────────────────────────────────────────────────────
34170
34171    #[test]
34172    fn fmt_git_date_valid_iso_returns_some() {
34173        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
34174    }
34175
34176    #[test]
34177    fn fmt_git_date_invalid_returns_none() {
34178        assert!(fmt_git_date("not-a-date").is_none());
34179    }
34180
34181    // ── format_number ─────────────────────────────────────────────────────────
34182
34183    #[test]
34184    fn format_number_zero() {
34185        assert_eq!(format_number(0), "0");
34186    }
34187
34188    #[test]
34189    fn format_number_three_digits_no_comma() {
34190        assert_eq!(format_number(999), "999");
34191    }
34192
34193    #[test]
34194    fn format_number_four_digits_has_comma() {
34195        assert_eq!(format_number(1000), "1,000");
34196    }
34197
34198    #[test]
34199    fn format_number_seven_digits_two_commas() {
34200        assert_eq!(format_number(1_234_567), "1,234,567");
34201    }
34202
34203    #[test]
34204    fn format_number_one_million() {
34205        assert_eq!(format_number(1_000_000), "1,000,000");
34206    }
34207
34208    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
34209
34210    #[test]
34211    fn badge_text_px_empty_is_zero() {
34212        assert_eq!(badge_text_px(""), 0);
34213    }
34214
34215    #[test]
34216    fn badge_text_px_narrow_chars_smaller_than_normal() {
34217        assert!(
34218            badge_text_px("if") < badge_text_px("ab"),
34219            "'if' must be narrower than 'ab'"
34220        );
34221    }
34222
34223    #[test]
34224    fn badge_text_px_m_is_wider_than_a() {
34225        assert!(
34226            badge_text_px("m") > badge_text_px("a"),
34227            "'m' must be wider than 'a'"
34228        );
34229    }
34230
34231    #[test]
34232    fn render_badge_svg_contains_label_and_value() {
34233        let svg = render_badge_svg("coverage", "95%", "#4c1");
34234        assert!(svg.contains("coverage") && svg.contains("95%"));
34235    }
34236
34237    #[test]
34238    fn render_badge_svg_contains_color() {
34239        let svg = render_badge_svg("sloc", "12K", "#e05d44");
34240        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
34241    }
34242
34243    #[test]
34244    fn render_badge_svg_escapes_ampersand_in_label() {
34245        let svg = render_badge_svg("test&label", "ok", "#4c1");
34246        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
34247    }
34248
34249    // ── build_pdf_filename ────────────────────────────────────────────────────
34250
34251    #[test]
34252    fn build_pdf_filename_slugifies_title() {
34253        let name = build_pdf_filename("My Project Report", "abc-def-1234");
34254        assert!(
34255            name.starts_with("my_project_report_")
34256                && std::path::Path::new(&name)
34257                    .extension()
34258                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34259        );
34260    }
34261
34262    #[test]
34263    fn build_pdf_filename_uses_last_run_id_segment() {
34264        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
34265        assert!(name.contains("ABCD"), "must use last segment of run_id");
34266    }
34267
34268    #[test]
34269    fn build_pdf_filename_empty_title_uses_report_prefix() {
34270        let name = build_pdf_filename("", "abc-def-9999");
34271        assert!(
34272            name.starts_with("report_")
34273                && std::path::Path::new(&name)
34274                    .extension()
34275                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
34276        );
34277    }
34278
34279    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
34280
34281    #[test]
34282    fn swap_chart_js_replaces_inline_block() {
34283        let html = "<html><head><script>// inline source</script></head><body></body></html>";
34284        let result = swap_inline_chart_js_for_static(html.to_string());
34285        assert!(result.contains(r#"src="/static/chart-report.js""#));
34286        assert!(!result.contains("inline source"));
34287    }
34288
34289    #[test]
34290    fn swap_chart_js_no_head_returns_unchanged() {
34291        let html = "<body>no head here</body>";
34292        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
34293    }
34294
34295    #[test]
34296    fn swap_chart_js_no_script_in_head_unchanged() {
34297        let html = "<html><head><style>.x{}</style></head><body></body></html>";
34298        let result = swap_inline_chart_js_for_static(html.to_string());
34299        assert!(!result.contains("chart-report.js"));
34300    }
34301
34302    // ── patch_html_nonce ──────────────────────────────────────────────────────
34303
34304    #[test]
34305    fn patch_html_nonce_replaces_old_nonce() {
34306        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
34307        let result = patch_html_nonce(html, "new-nonce-456");
34308        assert!(result.contains(r#"nonce="new-nonce-456""#));
34309        assert!(!result.contains("old-nonce-123"));
34310    }
34311
34312    #[test]
34313    fn patch_html_nonce_injects_into_bare_style() {
34314        let html = "<style>body{color:red;}</style>";
34315        let result = patch_html_nonce(html, "fresh-nonce");
34316        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
34317    }
34318
34319    #[test]
34320    fn patch_html_nonce_injects_into_bare_script() {
34321        let html = "<script>console.log(1);</script>";
34322        let result = patch_html_nonce(html, "abc");
34323        assert!(result.contains(r#"<script nonce="abc">"#));
34324    }
34325
34326    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
34327
34328    #[test]
34329    fn is_html_report_file_result_html_matches() {
34330        let dir = tempfile::tempdir().unwrap();
34331        let path = dir.path().join("result_20240101.html");
34332        std::fs::write(&path, b"<html></html>").unwrap();
34333        assert!(is_html_report_file(&path));
34334    }
34335
34336    #[test]
34337    fn is_html_report_file_report_html_matches() {
34338        let dir = tempfile::tempdir().unwrap();
34339        let path = dir.path().join("report_abc.html");
34340        std::fs::write(&path, b"<html></html>").unwrap();
34341        assert!(is_html_report_file(&path));
34342    }
34343
34344    #[test]
34345    fn is_html_report_file_index_html_does_not_match() {
34346        let dir = tempfile::tempdir().unwrap();
34347        let path = dir.path().join("index.html");
34348        std::fs::write(&path, b"<html></html>").unwrap();
34349        assert!(!is_html_report_file(&path));
34350    }
34351
34352    #[test]
34353    fn is_html_report_file_nonexistent_returns_false() {
34354        assert!(!is_html_report_file(Path::new(
34355            "/nonexistent/result_xyz.html"
34356        )));
34357    }
34358
34359    #[test]
34360    fn find_html_report_in_dir_finds_result_html() {
34361        let dir = tempfile::tempdir().unwrap();
34362        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34363        assert!(find_html_report_in_dir(dir.path()).is_some());
34364    }
34365
34366    #[test]
34367    fn find_html_report_in_dir_empty_returns_none() {
34368        let dir = tempfile::tempdir().unwrap();
34369        assert!(find_html_report_in_dir(dir.path()).is_none());
34370    }
34371
34372    #[test]
34373    fn find_html_report_in_tree_finds_in_subdir() {
34374        let dir = tempfile::tempdir().unwrap();
34375        let subdir = dir.path().join("run-001");
34376        std::fs::create_dir_all(&subdir).unwrap();
34377        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34378        assert!(find_html_report_in_tree(dir.path()).is_some());
34379    }
34380
34381    // ── derive_project_label ──────────────────────────────────────────────────
34382
34383    #[test]
34384    fn derive_project_label_with_git_repo_and_ref() {
34385        let label = derive_project_label(
34386            Some("https://github.com/owner/my-repo.git"),
34387            Some("main"),
34388            "/fallback/path",
34389        );
34390        assert!(!label.is_empty(), "label must not be empty");
34391        assert!(
34392            label.contains("my") || label.contains("repo"),
34393            "got: {label}"
34394        );
34395    }
34396
34397    #[test]
34398    fn derive_project_label_fallback_to_path() {
34399        let label = derive_project_label(None, None, "/path/to/myproject");
34400        assert_eq!(label, "myproject");
34401    }
34402
34403    #[test]
34404    fn derive_project_label_empty_git_fields_use_path() {
34405        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34406        assert_eq!(label, "cool-app");
34407    }
34408
34409    // ── derive_file_stem ──────────────────────────────────────────────────────
34410
34411    #[test]
34412    fn derive_file_stem_with_commit_appends_sha() {
34413        assert_eq!(
34414            derive_file_stem("myproject", Some("a1b2c3")),
34415            "myproject_a1b2c3"
34416        );
34417    }
34418
34419    #[test]
34420    fn derive_file_stem_without_commit_returns_label() {
34421        assert_eq!(derive_file_stem("myproject", None), "myproject");
34422    }
34423
34424    #[test]
34425    fn derive_file_stem_empty_commit_returns_label() {
34426        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34427    }
34428
34429    // ── split_patterns ────────────────────────────────────────────────────────
34430
34431    #[test]
34432    fn split_patterns_none_is_empty() {
34433        assert!(split_patterns(None).is_empty());
34434    }
34435
34436    #[test]
34437    fn split_patterns_empty_string_is_empty() {
34438        assert!(split_patterns(Some("")).is_empty());
34439    }
34440
34441    #[test]
34442    fn split_patterns_comma_separated() {
34443        assert_eq!(
34444            split_patterns(Some("foo,bar,baz")),
34445            vec!["foo", "bar", "baz"]
34446        );
34447    }
34448
34449    #[test]
34450    fn split_patterns_newline_separated() {
34451        assert_eq!(
34452            split_patterns(Some("foo\nbar\nbaz")),
34453            vec!["foo", "bar", "baz"]
34454        );
34455    }
34456
34457    #[test]
34458    fn split_patterns_trims_whitespace() {
34459        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
34460    }
34461
34462    // ── make_git_label ────────────────────────────────────────────────────────
34463
34464    #[test]
34465    fn make_git_label_empty_repo_empty_result() {
34466        assert_eq!(make_git_label("", "main"), "");
34467    }
34468
34469    #[test]
34470    fn make_git_label_empty_ref_empty_result() {
34471        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34472    }
34473
34474    #[test]
34475    fn make_git_label_basic_format() {
34476        assert_eq!(
34477            make_git_label("https://github.com/owner/my-repo.git", "main"),
34478            "my-repo_at_main_sloc"
34479        );
34480    }
34481
34482    #[test]
34483    fn make_git_label_slash_in_ref_replaced() {
34484        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34485        assert!(
34486            !label.contains('/'),
34487            "slash in ref must be replaced: {label}"
34488        );
34489    }
34490
34491    // ── format_dir_size ───────────────────────────────────────────────────────
34492
34493    #[test]
34494    fn format_dir_size_bytes() {
34495        assert_eq!(format_dir_size(500), "500 B");
34496    }
34497
34498    #[test]
34499    fn format_dir_size_kilobytes() {
34500        assert_eq!(format_dir_size(2048), "2 KB");
34501    }
34502
34503    #[test]
34504    fn format_dir_size_megabytes() {
34505        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34506    }
34507
34508    #[test]
34509    fn format_dir_size_gigabytes() {
34510        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34511    }
34512
34513    #[test]
34514    fn format_dir_size_zero() {
34515        assert_eq!(format_dir_size(0), "0 B");
34516    }
34517
34518    // ── civil_from_days ───────────────────────────────────────────────────────
34519
34520    #[test]
34521    fn civil_from_days_epoch() {
34522        assert_eq!(civil_from_days(0), (1970, 1, 1));
34523    }
34524
34525    #[test]
34526    fn civil_from_days_one_year_later() {
34527        assert_eq!(civil_from_days(365), (1971, 1, 1));
34528    }
34529
34530    #[test]
34531    fn civil_from_days_31_days_is_feb_1_1970() {
34532        assert_eq!(civil_from_days(31), (1970, 2, 1));
34533    }
34534
34535    // ── format_system_time ────────────────────────────────────────────────────
34536
34537    #[test]
34538    fn format_system_time_unix_epoch_formats_correctly() {
34539        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34540    }
34541
34542    #[test]
34543    fn format_system_time_31_days_after_epoch() {
34544        let t = UNIX_EPOCH + Duration::from_hours(744);
34545        assert_eq!(format_system_time(t), "1970-02-01 00:00");
34546    }
34547
34548    #[test]
34549    fn format_system_time_before_epoch_returns_dash() {
34550        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34551            assert_eq!(format_system_time(before), "-");
34552        }
34553    }
34554
34555    // ── detect_language_name ──────────────────────────────────────────────────
34556
34557    #[test]
34558    fn detect_language_name_dot_c() {
34559        assert_eq!(detect_language_name("main.c"), Some("C"));
34560    }
34561
34562    #[test]
34563    fn detect_language_name_dot_h() {
34564        assert_eq!(detect_language_name("defs.h"), Some("C"));
34565    }
34566
34567    #[test]
34568    fn detect_language_name_dot_cpp() {
34569        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34570    }
34571
34572    #[test]
34573    fn detect_language_name_dot_py() {
34574        assert_eq!(detect_language_name("script.py"), Some("Python"));
34575    }
34576
34577    #[test]
34578    fn detect_language_name_dot_ps1() {
34579        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34580    }
34581
34582    #[test]
34583    fn detect_language_name_dot_cs() {
34584        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34585    }
34586
34587    #[test]
34588    fn detect_language_name_dot_sh() {
34589        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34590    }
34591
34592    #[test]
34593    fn detect_language_name_unknown_txt() {
34594        assert_eq!(detect_language_name("notes.txt"), None);
34595    }
34596
34597    // ── language_icon_file ────────────────────────────────────────────────────
34598
34599    #[test]
34600    fn language_icon_file_c() {
34601        assert_eq!(language_icon_file("C"), Some("c.png"));
34602    }
34603
34604    #[test]
34605    fn language_icon_file_python() {
34606        assert_eq!(language_icon_file("Python"), Some("python.png"));
34607    }
34608
34609    #[test]
34610    fn language_icon_file_dockerfile() {
34611        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34612    }
34613
34614    #[test]
34615    fn language_icon_file_rust_is_none() {
34616        assert!(language_icon_file("Rust").is_none());
34617    }
34618
34619    #[test]
34620    fn language_icon_file_unknown_is_none() {
34621        assert!(language_icon_file("Fortran").is_none());
34622    }
34623
34624    // ── language_inline_svg ───────────────────────────────────────────────────
34625
34626    #[test]
34627    fn language_inline_svg_rust_is_svg() {
34628        let svg = language_inline_svg("Rust").unwrap();
34629        assert!(svg.starts_with("<svg"));
34630    }
34631
34632    #[test]
34633    fn language_inline_svg_typescript_is_some() {
34634        assert!(language_inline_svg("TypeScript").is_some());
34635    }
34636
34637    #[test]
34638    fn language_inline_svg_unknown_is_none() {
34639        assert!(language_inline_svg("Fortran").is_none());
34640    }
34641
34642    // ── classify_preview_file ─────────────────────────────────────────────────
34643
34644    #[test]
34645    fn classify_preview_file_c_supported() {
34646        assert!(matches!(
34647            classify_preview_file("main.c"),
34648            PreviewKind::Supported
34649        ));
34650    }
34651
34652    #[test]
34653    fn classify_preview_file_python_supported() {
34654        assert!(matches!(
34655            classify_preview_file("script.py"),
34656            PreviewKind::Supported
34657        ));
34658    }
34659
34660    #[test]
34661    fn classify_preview_file_png_skipped() {
34662        assert!(matches!(
34663            classify_preview_file("image.png"),
34664            PreviewKind::Skipped
34665        ));
34666    }
34667
34668    #[test]
34669    fn classify_preview_file_zip_skipped() {
34670        assert!(matches!(
34671            classify_preview_file("archive.zip"),
34672            PreviewKind::Skipped
34673        ));
34674    }
34675
34676    #[test]
34677    fn classify_preview_file_min_js_skipped() {
34678        assert!(matches!(
34679            classify_preview_file("bundle.min.js"),
34680            PreviewKind::Skipped
34681        ));
34682    }
34683
34684    #[test]
34685    fn classify_preview_file_rs_unsupported() {
34686        assert!(matches!(
34687            classify_preview_file("main.rs"),
34688            PreviewKind::Unsupported
34689        ));
34690    }
34691
34692    // ── preview_relative_path ─────────────────────────────────────────────────
34693
34694    #[test]
34695    fn preview_relative_path_strips_root() {
34696        let root = PathBuf::from("/project");
34697        let path = PathBuf::from("/project/src/main.c");
34698        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34699    }
34700
34701    #[test]
34702    fn preview_relative_path_unrooted_includes_filename() {
34703        let root = PathBuf::from("/other");
34704        let path = PathBuf::from("/project/src/main.c");
34705        let result = preview_relative_path(&root, &path);
34706        assert!(result.contains("main.c"));
34707    }
34708
34709    #[test]
34710    fn preview_relative_path_uses_forward_slashes() {
34711        let root = PathBuf::from("/project");
34712        let path = PathBuf::from("/project/a/b/c.py");
34713        assert!(!preview_relative_path(&root, &path).contains('\\'));
34714    }
34715
34716    // ── wildcard_match ────────────────────────────────────────────────────────
34717
34718    #[test]
34719    fn wildcard_match_exact_equal() {
34720        assert!(wildcard_match("foo", "foo"));
34721    }
34722
34723    #[test]
34724    fn wildcard_match_exact_mismatch() {
34725        assert!(!wildcard_match("foo", "bar"));
34726    }
34727
34728    #[test]
34729    fn wildcard_match_star_suffix() {
34730        assert!(wildcard_match("*.rs", "main.rs"));
34731    }
34732
34733    #[test]
34734    fn wildcard_match_star_middle_requires_suffix() {
34735        assert!(!wildcard_match("a*b", "ac"));
34736    }
34737
34738    #[test]
34739    fn wildcard_match_question_mark_single_char() {
34740        assert!(wildcard_match("f?o", "foo"));
34741    }
34742
34743    #[test]
34744    fn wildcard_match_double_star_nested() {
34745        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34746    }
34747
34748    #[test]
34749    fn wildcard_match_star_directory_entry() {
34750        assert!(wildcard_match("vendor/*", "vendor/crate"));
34751    }
34752
34753    #[test]
34754    fn wildcard_match_no_cross_prefix() {
34755        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34756    }
34757
34758    // ── should_skip_preview_directory ────────────────────────────────────────
34759
34760    #[test]
34761    fn should_skip_empty_relative_is_false() {
34762        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34763    }
34764
34765    #[test]
34766    fn should_skip_matching_pattern() {
34767        assert!(should_skip_preview_directory(
34768            "vendor",
34769            &["vendor".to_string()]
34770        ));
34771    }
34772
34773    #[test]
34774    fn should_skip_non_matching() {
34775        assert!(!should_skip_preview_directory(
34776            "src",
34777            &["vendor".to_string()]
34778        ));
34779    }
34780
34781    #[test]
34782    fn should_skip_wildcard_prefix() {
34783        assert!(should_skip_preview_directory(
34784            "target/debug",
34785            &["target*".to_string()]
34786        ));
34787    }
34788
34789    // ── should_include_preview_file ───────────────────────────────────────────
34790
34791    #[test]
34792    fn should_include_empty_relative_always_true() {
34793        assert!(should_include_preview_file("", &[], &[]));
34794    }
34795
34796    #[test]
34797    fn should_include_no_patterns_includes_all() {
34798        assert!(should_include_preview_file("src/main.c", &[], &[]));
34799    }
34800
34801    #[test]
34802    fn should_include_excluded_by_pattern() {
34803        assert!(!should_include_preview_file(
34804            "vendor/lib.c",
34805            &[],
34806            &["vendor/*".to_string()]
34807        ));
34808    }
34809
34810    #[test]
34811    fn should_include_include_pattern_filters() {
34812        assert!(!should_include_preview_file(
34813            "tests/test_foo.c",
34814            &["src/*".to_string()],
34815            &[]
34816        ));
34817    }
34818
34819    // ── escape_html ───────────────────────────────────────────────────────────
34820
34821    #[test]
34822    fn escape_html_ampersand() {
34823        assert_eq!(escape_html("a&b"), "a&amp;b");
34824    }
34825
34826    #[test]
34827    fn escape_html_angle_brackets() {
34828        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
34829    }
34830
34831    #[test]
34832    fn escape_html_double_quote() {
34833        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
34834    }
34835
34836    #[test]
34837    fn escape_html_single_quote() {
34838        assert_eq!(escape_html("it's"), "it&#39;s");
34839    }
34840
34841    #[test]
34842    fn escape_html_plain_text_unchanged() {
34843        assert_eq!(escape_html("hello world"), "hello world");
34844    }
34845
34846    // ── sum_added / removed / unmodified code lines ───────────────────────────
34847
34848    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34849        sloc_core::ScanComparison {
34850            summary: sloc_core::SummaryDelta {
34851                baseline_run_id: "base".to_string(),
34852                current_run_id: "curr".to_string(),
34853                baseline_timestamp: chrono::Utc::now(),
34854                current_timestamp: chrono::Utc::now(),
34855                baseline_files: 4,
34856                current_files: 4,
34857                files_analyzed_delta: 0,
34858                baseline_code: 330,
34859                current_code: 400,
34860                code_lines_delta: 70,
34861                baseline_comments: 0,
34862                current_comments: 0,
34863                comment_lines_delta: 0,
34864                blank_lines_delta: 0,
34865                total_lines_delta: 70,
34866                coverage_lines_hit_delta: None,
34867                coverage_line_pct_delta: None,
34868                baseline_coverage_line_pct: None,
34869                current_coverage_line_pct: None,
34870            },
34871            file_deltas: vec![
34872                sloc_core::FileDelta {
34873                    relative_path: "added.rs".to_string(),
34874                    language: Some("Rust".to_string()),
34875                    status: FileChangeStatus::Added,
34876                    baseline_code: 0,
34877                    current_code: 100,
34878                    code_delta: 100,
34879                    baseline_comment: 0,
34880                    current_comment: 0,
34881                    comment_delta: 0,
34882                    baseline_blank: 0,
34883                    current_blank: 0,
34884                    blank_delta: 0,
34885                    total_delta: 100,
34886                },
34887                sloc_core::FileDelta {
34888                    relative_path: "removed.rs".to_string(),
34889                    language: Some("Rust".to_string()),
34890                    status: FileChangeStatus::Removed,
34891                    baseline_code: 50,
34892                    current_code: 0,
34893                    code_delta: -50,
34894                    baseline_comment: 0,
34895                    current_comment: 0,
34896                    comment_delta: 0,
34897                    baseline_blank: 0,
34898                    current_blank: 0,
34899                    blank_delta: 0,
34900                    total_delta: -50,
34901                },
34902                sloc_core::FileDelta {
34903                    relative_path: "modified.rs".to_string(),
34904                    language: Some("Rust".to_string()),
34905                    status: FileChangeStatus::Modified,
34906                    baseline_code: 80,
34907                    current_code: 100,
34908                    code_delta: 20,
34909                    baseline_comment: 0,
34910                    current_comment: 0,
34911                    comment_delta: 0,
34912                    baseline_blank: 0,
34913                    current_blank: 0,
34914                    blank_delta: 0,
34915                    total_delta: 20,
34916                },
34917                sloc_core::FileDelta {
34918                    relative_path: "unchanged.rs".to_string(),
34919                    language: Some("Rust".to_string()),
34920                    status: FileChangeStatus::Unchanged,
34921                    baseline_code: 200,
34922                    current_code: 200,
34923                    code_delta: 0,
34924                    baseline_comment: 0,
34925                    current_comment: 0,
34926                    comment_delta: 0,
34927                    baseline_blank: 0,
34928                    current_blank: 0,
34929                    blank_delta: 0,
34930                    total_delta: 0,
34931                },
34932            ],
34933            files_added: 1,
34934            files_removed: 1,
34935            files_modified: 1,
34936            files_unchanged: 1,
34937            files_total: 4,
34938        }
34939    }
34940
34941    #[test]
34942    fn sum_added_counts_added_and_positive_modified() {
34943        let cmp = make_mixed_scan_comparison();
34944        assert_eq!(sum_added_code_lines(&cmp), 120);
34945    }
34946
34947    #[test]
34948    fn sum_removed_counts_removed_baseline() {
34949        let cmp = make_mixed_scan_comparison();
34950        assert_eq!(sum_removed_code_lines(&cmp), 50);
34951    }
34952
34953    #[test]
34954    fn sum_unmodified_counts_unchanged_files() {
34955        let cmp = make_mixed_scan_comparison();
34956        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34957    }
34958
34959    // ── detect_coverage_tool ──────────────────────────────────────────────────
34960
34961    #[test]
34962    fn detect_coverage_tool_rust_project() {
34963        let dir = tempfile::tempdir().unwrap();
34964        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34965        let (tool, cmd) = detect_coverage_tool(dir.path());
34966        assert_eq!(tool, Some("cargo-llvm-cov"));
34967        assert!(cmd.is_some());
34968    }
34969
34970    #[test]
34971    fn detect_coverage_tool_java_gradle() {
34972        let dir = tempfile::tempdir().unwrap();
34973        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34974        let (tool, _) = detect_coverage_tool(dir.path());
34975        assert_eq!(tool, Some("jacoco"));
34976    }
34977
34978    #[test]
34979    fn detect_coverage_tool_python_pyproject() {
34980        let dir = tempfile::tempdir().unwrap();
34981        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34982        let (tool, _) = detect_coverage_tool(dir.path());
34983        assert_eq!(tool, Some("pytest-cov"));
34984    }
34985
34986    #[test]
34987    fn detect_coverage_tool_unknown_project() {
34988        let dir = tempfile::tempdir().unwrap();
34989        let (tool, cmd) = detect_coverage_tool(dir.path());
34990        assert!(tool.is_none() && cmd.is_none());
34991    }
34992
34993    // ── sanitize_path_str / display_path ─────────────────────────────────────
34994
34995    #[test]
34996    fn sanitize_path_str_unc_drive_stripped() {
34997        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34998    }
34999
35000    #[test]
35001    fn sanitize_path_str_unc_network_stripped() {
35002        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
35003    }
35004
35005    #[test]
35006    fn sanitize_path_str_plain_path_unchanged() {
35007        assert_eq!(
35008            sanitize_path_str("/home/user/project"),
35009            "/home/user/project"
35010        );
35011    }
35012
35013    #[test]
35014    fn display_path_plain_linux_unchanged() {
35015        assert_eq!(
35016            display_path(Path::new("/home/user/project")),
35017            "/home/user/project"
35018        );
35019    }
35020
35021    #[test]
35022    fn display_path_unc_drive_stripped() {
35023        let result = display_path(Path::new(r"\\?\C:\Users\user"));
35024        assert_eq!(result, r"C:\Users\user");
35025    }
35026
35027    #[test]
35028    fn display_path_unc_network_stripped() {
35029        let result = display_path(Path::new(r"\\?\UNC\server\share"));
35030        assert_eq!(result, r"\\server\share");
35031    }
35032}
35033
35034#[cfg(test)]
35035mod coverage_boost_unit_tests {
35036    use super::*;
35037    use std::path::{Path, PathBuf};
35038
35039    // Both scenarios live in one test (sequential, under a Tokio runtime) because
35040    // load_runtime_security_config spawns a pruning task and mutates process-global
35041    // env vars — parallel sub-tests would race on both.
35042    #[tokio::test]
35043    async fn runtime_security_config_scenarios() {
35044        // FIXME: Audit that the environment access only happens in single-threaded code.
35045        unsafe { std::env::remove_var("SLOC_API_KEYS") };
35046        // FIXME: Audit that the environment access only happens in single-threaded code.
35047        unsafe { std::env::remove_var("SLOC_API_KEY") };
35048        // FIXME: Audit that the environment access only happens in single-threaded code.
35049        unsafe { std::env::remove_var("SLOC_TLS_CERT") };
35050        // FIXME: Audit that the environment access only happens in single-threaded code.
35051        unsafe { std::env::remove_var("SLOC_TLS_KEY") };
35052        // FIXME: Audit that the environment access only happens in single-threaded code.
35053        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
35054        // FIXME: Audit that the environment access only happens in single-threaded code.
35055        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
35056        let cfg = load_runtime_security_config(false);
35057        assert!(cfg.api_keys.is_empty());
35058        assert!(!cfg.tls_enabled);
35059        assert!(!cfg.trust_proxy);
35060
35061        // FIXME: Audit that the environment access only happens in single-threaded code.
35062        unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
35063        // FIXME: Audit that the environment access only happens in single-threaded code.
35064        unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
35065        // FIXME: Audit that the environment access only happens in single-threaded code.
35066        unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
35067        // FIXME: Audit that the environment access only happens in single-threaded code.
35068        unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
35069        // FIXME: Audit that the environment access only happens in single-threaded code.
35070        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
35071        // FIXME: Audit that the environment access only happens in single-threaded code.
35072        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
35073        let cfg = load_runtime_security_config(true);
35074        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
35075        assert!(cfg.trust_proxy);
35076        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
35077        // FIXME: Audit that the environment access only happens in single-threaded code.
35078        unsafe { std::env::remove_var("SLOC_API_KEYS") };
35079        // FIXME: Audit that the environment access only happens in single-threaded code.
35080        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
35081        // FIXME: Audit that the environment access only happens in single-threaded code.
35082        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
35083        // FIXME: Audit that the environment access only happens in single-threaded code.
35084        unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
35085        // FIXME: Audit that the environment access only happens in single-threaded code.
35086        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
35087        // FIXME: Audit that the environment access only happens in single-threaded code.
35088        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
35089    }
35090
35091    #[test]
35092    fn cors_layer_builds_both_modes() {
35093        let _ = build_cors_layer(true);
35094        let _ = build_cors_layer(false);
35095    }
35096
35097    #[test]
35098    fn primary_lan_ip_callable() {
35099        // May be Some or None depending on the host; both are valid.
35100        let _ = primary_lan_ip();
35101    }
35102
35103    #[test]
35104    fn safe_redirect_allows_relative_rejects_absolute() {
35105        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
35106        assert_eq!(safe_redirect("https://evil.example/x"), "/");
35107        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
35108        assert_eq!(default_redirect(), "/view-reports");
35109    }
35110
35111    #[test]
35112    fn tarball_size_caps_env_override() {
35113        // FIXME: Audit that the environment access only happens in single-threaded code.
35114        unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
35115        // FIXME: Audit that the environment access only happens in single-threaded code.
35116        unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
35117        let (c, d) = parse_tarball_size_caps();
35118        assert_eq!(c, 1024 * 1024);
35119        assert_eq!(d, 2 * 1024 * 1024);
35120        // FIXME: Audit that the environment access only happens in single-threaded code.
35121        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
35122        // FIXME: Audit that the environment access only happens in single-threaded code.
35123        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
35124        let (c2, _) = parse_tarball_size_caps();
35125        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
35126    }
35127
35128    #[test]
35129    fn upload_path_helpers() {
35130        let base = upload_base_dir();
35131        let staged = upload_staging_path("abc123");
35132        assert!(staged.starts_with(&base));
35133        assert!(
35134            is_upload_tmp_path(&staged),
35135            "staging path is an upload tmp path"
35136        );
35137        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
35138    }
35139
35140    #[test]
35141    fn git_clones_dir_env_override() {
35142        // FIXME: Audit that the environment access only happens in single-threaded code.
35143        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35144        let def = resolve_git_clones_dir(Path::new("/out"));
35145        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
35146        // FIXME: Audit that the environment access only happens in single-threaded code.
35147        unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
35148        assert_eq!(
35149            resolve_git_clones_dir(Path::new("/out")),
35150            PathBuf::from("/custom/clones")
35151        );
35152        // FIXME: Audit that the environment access only happens in single-threaded code.
35153        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
35154    }
35155
35156    #[test]
35157    fn html_report_file_detection() {
35158        let dir = std::env::temp_dir().join("sloc_html_detect");
35159        let _ = std::fs::create_dir_all(&dir);
35160        let good = dir.join("report_x.html");
35161        std::fs::write(&good, "<html></html>").unwrap();
35162        let bad = dir.join("notes.txt");
35163        std::fs::write(&bad, "x").unwrap();
35164        assert!(is_html_report_file(&good));
35165        assert!(!is_html_report_file(&bad));
35166        assert!(find_html_report_in_dir(&dir).is_some());
35167        let _ = std::fs::remove_dir_all(&dir);
35168    }
35169
35170    #[test]
35171    fn multi_delta_class_and_format() {
35172        assert_eq!(multi_delta_class(5), "pos");
35173        assert_eq!(multi_delta_class(-5), "neg");
35174        assert_eq!(multi_delta_class(0), "zero");
35175        assert_eq!(multi_fmt_delta(3), "+3");
35176        assert_eq!(multi_fmt_delta(-3), "-3");
35177        assert_eq!(multi_fmt_delta(0), "0");
35178    }
35179
35180    #[test]
35181    fn git_clone_dest_sanitizes() {
35182        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
35183        assert!(dest.starts_with("/clones"));
35184        let name = dest.file_name().unwrap().to_str().unwrap();
35185        assert!(
35186            name.chars()
35187                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
35188        );
35189    }
35190}
35191
35192#[cfg(test)]
35193mod tests_private {
35194    use super::*;
35195    use std::io::Read;
35196
35197    // ── Server-mode fail-closed auth gate ──────────────────────────────────────
35198
35199    #[test]
35200    fn local_mode_never_refuses_start() {
35201        // Desktop / local mode is open by design regardless of key presence.
35202        assert!(!refuse_unauthenticated_server(false, false));
35203        assert!(!refuse_unauthenticated_server(false, true));
35204    }
35205
35206    #[test]
35207    fn server_mode_with_key_is_allowed() {
35208        assert!(!refuse_unauthenticated_server(true, true));
35209    }
35210
35211    // Env-mutating assertions live in one test so they run sequentially: the
35212    // process-global env var would otherwise race across parallel test threads.
35213    #[test]
35214    fn server_mode_auth_gate_respects_optin() {
35215        // FIXME: Audit that the environment access only happens in single-threaded code.
35216        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35217        assert!(
35218            refuse_unauthenticated_server(true, false),
35219            "server mode + no key must fail closed by default"
35220        );
35221        // FIXME: Audit that the environment access only happens in single-threaded code.
35222        unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
35223        assert!(
35224            !refuse_unauthenticated_server(true, false),
35225            "explicit opt-in must allow the unauthenticated server"
35226        );
35227        // FIXME: Audit that the environment access only happens in single-threaded code.
35228        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
35229    }
35230
35231    // ── Health checks & response compression helpers ───────────────────────────
35232
35233    #[test]
35234    fn dir_writable_true_for_temp_dir() {
35235        assert!(dir_writable(&std::env::temp_dir()));
35236    }
35237
35238    #[test]
35239    fn dir_writable_empty_path_is_ok() {
35240        assert!(dir_writable(std::path::Path::new("")));
35241    }
35242
35243    #[test]
35244    fn is_compressible_type_matches_text_and_json() {
35245        assert!(is_compressible_type("text/html; charset=utf-8"));
35246        assert!(is_compressible_type("application/json"));
35247        assert!(is_compressible_type("image/svg+xml"));
35248        assert!(is_compressible_type("application/javascript"));
35249        assert!(!is_compressible_type("application/pdf"));
35250        assert!(!is_compressible_type("application/gzip"));
35251        assert!(!is_compressible_type("image/png"));
35252        assert!(!is_compressible_type(""));
35253    }
35254
35255    #[test]
35256    fn client_accepts_gzip_parses_header() {
35257        let mut h = axum::http::HeaderMap::new();
35258        assert!(!client_accepts_gzip(&h));
35259        h.insert(
35260            header::ACCEPT_ENCODING,
35261            HeaderValue::from_static("br, gzip, deflate"),
35262        );
35263        assert!(client_accepts_gzip(&h));
35264        h.insert(
35265            header::ACCEPT_ENCODING,
35266            HeaderValue::from_static("identity"),
35267        );
35268        assert!(!client_accepts_gzip(&h));
35269    }
35270
35271    #[test]
35272    fn http_timeout_defaults_are_sane() {
35273        // Whatever the ambient env, the timeout is always a positive duration.
35274        assert!(http_timeout() >= std::time::Duration::from_secs(1));
35275    }
35276
35277    #[test]
35278    fn uptime_seconds_is_monotonic_nonpanicking() {
35279        // Anchors the clock and returns a value without panicking.
35280        let _ = uptime_seconds();
35281    }
35282
35283    // ── Zip-slip / path-traversal on tarball extraction ────────────────────────
35284
35285    /// Hand-build a raw USTAR block for `name`/`data`, bypassing `tar::Builder`
35286    /// (which refuses to *write* a `..` path). This lets us feed the *reader* a
35287    /// genuinely malicious archive, which is where the zip-slip guard must hold.
35288    fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
35289        let mut h = [0u8; 512];
35290        let nb = name.as_bytes();
35291        h[..nb.len()].copy_from_slice(nb);
35292        h[100..108].copy_from_slice(b"0000644\0");
35293        h[108..116].copy_from_slice(b"0000000\0");
35294        h[116..124].copy_from_slice(b"0000000\0");
35295        h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
35296        h[136..148].copy_from_slice(b"00000000000\0");
35297        h[156] = b'0'; // typeflag: regular file
35298        h[257..263].copy_from_slice(b"ustar\0");
35299        h[263..265].copy_from_slice(b"00");
35300        for b in &mut h[148..156] {
35301            *b = b' ';
35302        }
35303        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
35304        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
35305
35306        let mut out = h.to_vec();
35307        out.extend_from_slice(data);
35308        out.resize(out.len() + (512 - data.len() % 512) % 512, 0); // pad file to 512
35309        out.resize(out.len() + 1024, 0); // two trailing zero blocks
35310        out
35311    }
35312
35313    /// A malicious tar whose entry path escapes the destination via `..` must not
35314    /// write outside the staging directory. Locks in the `tar::Archive::unpack`
35315    /// zip-slip guard as a regression test.
35316    #[tokio::test]
35317    async fn tarball_extraction_blocks_zip_slip() {
35318        use std::io::Write as _;
35319
35320        let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
35321        let staging = base.join("staging");
35322        let tar_gz = base.join("evil.tar.gz");
35323        std::fs::create_dir_all(&base).unwrap();
35324
35325        // Write a gzip-compressed tar whose single entry is "../escaped.txt".
35326        {
35327            let f = std::fs::File::create(&tar_gz).unwrap();
35328            let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
35329            enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
35330                .unwrap();
35331            enc.finish().unwrap().flush().unwrap();
35332        }
35333
35334        // Extraction must not write the escaped file beside the staging directory.
35335        let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
35336
35337        let escaped = base.join("escaped.txt");
35338        assert!(
35339            !escaped.exists(),
35340            "zip-slip entry escaped staging to {}",
35341            escaped.display()
35342        );
35343
35344        let _ = std::fs::remove_dir_all(&base);
35345    }
35346
35347    #[test]
35348    fn size_limit_reader_zero_remaining_returns_error() {
35349        let data = b"hello world";
35350        let mut reader = SizeLimitReader {
35351            inner: &data[..],
35352            remaining: 0,
35353        };
35354        let mut buf = [0u8; 4];
35355        assert!(reader.read(&mut buf).is_err());
35356    }
35357
35358    #[test]
35359    fn size_limit_reader_counts_bytes() {
35360        let data = b"hello world";
35361        let mut reader = SizeLimitReader {
35362            inner: &data[..],
35363            remaining: 5,
35364        };
35365        let mut buf = [0u8; 4];
35366        let n = reader.read(&mut buf).unwrap();
35367        assert_eq!(n, 4);
35368        assert_eq!(reader.remaining, 1);
35369    }
35370
35371    #[test]
35372    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
35373        let uuid = "12345678-1234-1234-1234-123456789012";
35374        let (id, path) = resolve_or_create_staging(Some(uuid));
35375        assert_eq!(id, uuid);
35376        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
35377    }
35378
35379    #[test]
35380    fn resolve_or_create_staging_with_none_creates_new() {
35381        let (id1, _) = resolve_or_create_staging(None);
35382        let (id2, _) = resolve_or_create_staging(None);
35383        assert_ne!(id1, id2);
35384    }
35385
35386    #[test]
35387    fn resolve_or_create_staging_with_path_separator_creates_new() {
35388        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
35389        let (id, _) = resolve_or_create_staging(Some("has/slash"));
35390        assert_ne!(id, "has/slash");
35391    }
35392
35393    #[test]
35394    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
35395        use std::net::IpAddr;
35396        use std::str::FromStr;
35397        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
35398        let ip = IpAddr::from_str("192.168.1.1").unwrap();
35399        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
35400    }
35401
35402    #[test]
35403    fn is_auth_locked_out_expired_entry_removed() {
35404        use std::net::IpAddr;
35405        use std::str::FromStr;
35406        let limiter = IpRateLimiter::new(
35407            Duration::from_mins(1),
35408            100,
35409            1, // 1 failure triggers lockout
35410            Duration::from_millis(1),
35411        );
35412        let ip = IpAddr::from_str("192.168.1.2").unwrap();
35413        limiter.record_auth_failure(ip);
35414        // Wait for the 1ms window to expire
35415        std::thread::sleep(Duration::from_millis(10));
35416        // Expired entry should be removed, returning false
35417        assert!(!limiter.is_auth_locked_out(ip));
35418    }
35419
35420    #[test]
35421    fn is_auth_locked_out_within_window_returns_true() {
35422        use std::net::IpAddr;
35423        use std::str::FromStr;
35424        let limiter = IpRateLimiter::new(
35425            Duration::from_mins(1),
35426            100,
35427            2, // 2 failures triggers lockout
35428            Duration::from_hours(1),
35429        );
35430        let ip = IpAddr::from_str("192.168.1.3").unwrap();
35431        limiter.record_auth_failure(ip);
35432        limiter.record_auth_failure(ip);
35433        assert!(limiter.is_auth_locked_out(ip));
35434    }
35435
35436    // ── output_folder_hint ───────────────────────────────────────────────────────
35437
35438    #[test]
35439    fn output_folder_hint_strips_json_subdir() {
35440        use std::path::Path;
35441        let path = Path::new("/output/scan1/json/result.json");
35442        let hint = output_folder_hint(path);
35443        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35444    }
35445
35446    #[test]
35447    fn output_folder_hint_strips_html_subdir() {
35448        use std::path::Path;
35449        let path = Path::new("/output/scan1/html/report.html");
35450        let hint = output_folder_hint(path);
35451        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35452    }
35453
35454    #[test]
35455    fn output_folder_hint_strips_pdf_subdir() {
35456        use std::path::Path;
35457        let path = Path::new("/output/scan1/pdf/report.pdf");
35458        let hint = output_folder_hint(path);
35459        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35460    }
35461
35462    #[test]
35463    fn output_folder_hint_strips_excel_subdir() {
35464        use std::path::Path;
35465        let path = Path::new("/output/scan1/excel/report.xlsx");
35466        let hint = output_folder_hint(path);
35467        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35468    }
35469
35470    #[test]
35471    fn output_folder_hint_flat_layout_returns_direct_parent() {
35472        use std::path::Path;
35473        let path = Path::new("/output/scan1/result.json");
35474        let hint = output_folder_hint(path);
35475        assert!(
35476            hint.ends_with("scan1"),
35477            "expected direct parent, got: {hint}"
35478        );
35479    }
35480
35481    #[test]
35482    fn output_folder_hint_other_subdir_name_not_stripped() {
35483        use std::path::Path;
35484        // "data" is not one of the named artifact subdirs — parent is kept as-is
35485        let path = Path::new("/output/scan1/data/result.json");
35486        let hint = output_folder_hint(path);
35487        assert!(
35488            hint.ends_with("data"),
35489            "non-artifact subdir must not be stripped, got: {hint}"
35490        );
35491    }
35492
35493    // ── find_file_by_ext ─────────────────────────────────────────────────────────
35494
35495    #[test]
35496    fn find_file_by_ext_finds_matching_file() {
35497        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35498        let _ = fs::create_dir_all(&dir);
35499        let f = dir.join("report.pdf");
35500        let _ = fs::write(&f, b"dummy");
35501        let result = find_file_by_ext(&dir, "pdf");
35502        assert!(result.is_some(), "expected to find report.pdf");
35503        let _ = fs::remove_dir_all(&dir);
35504    }
35505
35506    #[test]
35507    fn find_file_by_ext_returns_none_for_missing_ext() {
35508        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35509        let _ = fs::create_dir_all(&dir);
35510        let f = dir.join("report.json");
35511        let _ = fs::write(&f, b"{}");
35512        let result = find_file_by_ext(&dir, "pdf");
35513        assert!(result.is_none());
35514        let _ = fs::remove_dir_all(&dir);
35515    }
35516
35517    #[test]
35518    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35519        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35520        assert!(find_file_by_ext(dir, "json").is_none());
35521    }
35522
35523    // ── collect_result_json_candidates ───────────────────────────────────────────
35524
35525    #[test]
35526    fn collect_result_json_candidates_flat_root() {
35527        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35528        let _ = fs::create_dir_all(&root);
35529        let _ = fs::write(root.join("result.json"), b"{}");
35530        let candidates = collect_result_json_candidates(&root);
35531        assert!(!candidates.is_empty(), "should find result.json at root");
35532        let _ = fs::remove_dir_all(&root);
35533    }
35534
35535    #[test]
35536    fn collect_result_json_candidates_legacy_subdir() {
35537        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35538        let sub = root.join("scanA");
35539        let _ = fs::create_dir_all(&sub);
35540        let _ = fs::write(sub.join("result.json"), b"{}");
35541        let candidates = collect_result_json_candidates(&root);
35542        assert!(
35543            !candidates.is_empty(),
35544            "should find result.json in legacy subdir"
35545        );
35546        let _ = fs::remove_dir_all(&root);
35547    }
35548
35549    #[test]
35550    fn collect_result_json_candidates_structured_json_subdir() {
35551        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35552        let json_sub = root.join("scanB").join("json");
35553        let _ = fs::create_dir_all(&json_sub);
35554        let _ = fs::write(json_sub.join("result.json"), b"{}");
35555        let candidates = collect_result_json_candidates(&root);
35556        assert!(
35557            !candidates.is_empty(),
35558            "should find result.json inside <subdir>/json/"
35559        );
35560        let _ = fs::remove_dir_all(&root);
35561    }
35562
35563    #[test]
35564    fn collect_result_json_candidates_empty_dir() {
35565        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35566        let _ = fs::create_dir_all(&root);
35567        let candidates = collect_result_json_candidates(&root);
35568        assert!(candidates.is_empty());
35569        let _ = fs::remove_dir_all(&root);
35570    }
35571}